Skip to content

Commit

Permalink
Merge pull request RedHatInsights#1188 from astrozzc/umb
Browse files Browse the repository at this point in the history
[RHCLOUD-35453] Process user creation event
  • Loading branch information
astrozzc authored Oct 2, 2024
2 parents bf09e1e + 42f974d commit d7ff7d5
Show file tree
Hide file tree
Showing 11 changed files with 295 additions and 88 deletions.
5 changes: 5 additions & 0 deletions deploy/rbac-clowdapp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ objects:
optional: true
- name: PRINCIPAL_CLEANUP_DELETION_ENABLED_UMB
value: ${PRINCIPAL_CLEANUP_DELETION_ENABLED_UMB}
- name: UMB_JOB_ENABLED
value: ${UMB_JOB_ENABLED}

- name: service
minReplicas: ${{MIN_REPLICAS}}
Expand Down Expand Up @@ -895,6 +897,9 @@ parameters:
- name: PRINCIPAL_CLEANUP_DELETION_ENABLED_UMB
description: Allow cleanup job to delete principals via messages from UMB
value: 'False'
- name: UMB_JOB_ENABLE
description: Temp env to enable the UMB job
value: 'True'
- name: UMB_HOST
description: Host of the UMB service
value: 'localhost'
Expand Down
18 changes: 18 additions & 0 deletions rbac/management/migrations/0051_alter_principal_user_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-10-01 15:32

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("management", "0050_principal_user_id_alter_principal_type"),
]

operations = [
migrations.AlterField(
model_name="principal",
name="user_id",
field=models.CharField(db_index=True, max_length=256, null=True),
),
]
154 changes: 100 additions & 54 deletions rbac/management/principal/cleaner.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@
import logging
import os
import ssl
from collections import defaultdict

import xmltodict
from django.conf import settings
from management.principal.model import Principal
from management.principal.proxy import PrincipalProxy
from management.principal.utils import (
create_tenant_relationships,
create_user_relationships,
remove_user_relationships,
)
from rest_framework import status
from stompest.config import StompConfig
from stompest.error import StompConnectionError
Expand All @@ -36,7 +40,7 @@

logger = logging.getLogger(__name__) # pylint: disable=invalid-name

proxy = PrincipalProxy() # pylint: disable=invalid-name
PROXY = PrincipalProxy() # pylint: disable=invalid-name
CERT_LOC = "/opt/rbac/rbac/management/principal/umb_certs/cert.pem"
KEY_LOC = "/opt/rbac/rbac/management/principal/umb_certs/key.pem"

Expand All @@ -54,7 +58,7 @@ def clean_tenant_principals(tenant):
continue
logger.debug("clean_tenant_principals: Checking for username %s for tenant %s.", principal.username, tenant_id)
org_id = tenant.org_id
resp = proxy.request_filtered_principals([principal.username], org_id=org_id)
resp = PROXY.request_filtered_principals([principal.username], org_id=org_id)
status_code = resp.get("status_code")
data = resp.get("data")
logger.info("clean_tenant_principals: Response code: %s Data: %s", str(status_code), str(data))
Expand Down Expand Up @@ -119,45 +123,98 @@ def clean_tenants_principals():
UMB_CLIENT = Stomp(CONFIG)


def is_umb_deactivate_msg(data_dict):
"""Check if the message is a user deactivation message from UMB."""
if not data_dict.get("CanonicalMessage"): # Skip if it is not CanonicalMessage
return False
# We only care about disabled user, operation == update and status == Inactive
operation = data_dict["CanonicalMessage"].get("Header", {}).get("Operation")
if operation != "update":
return False
status = data_dict["CanonicalMessage"].get("Payload", {}).get("Sync").get("User", {}).get("Status", {})
if status.get("@primary") != "true" or status.get("State") != "Inactive":
return False

return True


def clean_principal_umb(data_dict):
"""Delete the principal if it exists."""
user_principal_login = data_dict["CanonicalMessage"]["Payload"]["Sync"]["User"]["Person"]["Credentials"]["Login"]
# In case the user is under multiple account
principals = (
Principal.objects.filter(username=user_principal_login)
.exclude(cross_account=True)
.exclude(type=Principal.Types.SERVICE_ACCOUNT)
def process_principal_deletion(user_data):
"""Process the principal deletion."""
# TODO: cleanup the relationships in spicedb
user_id = user_data["user_id"]
groups = []
tenant = Tenant.objects.get(org_id=user_data["org_id"])
principal = Principal.objects.filter(username=user_data["username"], tenant=tenant).first()
if not principal: # User not in RBAC
return

# Log the group info in case it is needed
for group in principal.group.all():
groups.append(group)
# We have to do the removal explicitly in order to clear the cache,
# or the console will still show the cached number of members
group.principals.remove(principal)
principal.delete()
remove_user_relationships(tenant, groups, principal, user_data["is_org_admin"])
if not groups:
logger.info(f"Principal {user_id} was not under any groups.")
for group in groups:
logger.info(f"Principal {user_id} was in group with uuid: {group.uuid}")


def process_principal_edit(user_data):
"""Process the principal update."""
org_id = user_data["org_id"]
tenant_name = f"org{org_id}"
tenant, created = Tenant.objects.get_or_create(org_id=org_id, defaults={"ready": True, "tenant_name": tenant_name})
if created:
create_tenant_relationships(tenant)
principal, created = Principal.objects.get_or_create(
username=user_data["username"],
tenant=tenant,
defaults={"user_id": user_data["user_id"]},
)
groups = defaultdict(list)
for principal in principals:
# Log the group info in case it is needed
for group in principal.group.all():
groups[principal.tenant.tenant_name].append(group.name)
# We have to trigger the removal in order to clear the cache, or the console will still show the cached
# number of members
group.principals.remove(principal)
principal.delete()
return user_principal_login, groups


def clean_principals_via_umb():
"""Check which principals are eligible for clean up via UMB."""
logger.info("clean_tenant_principals: Start principal clean up via umb.")
if created:
create_user_relationships(principal, user_data["is_org_admin"])


def retrieve_user_info(message):
"""
Retrieve user info from the message.
returns:
user_data
is_deleted # Has the user been deleted on IT's side
"""
user = message["Payload"]["Sync"]["User"]
identifiers = user["Identifiers"]
user_id = identifiers["Identifier"]["#text"]

bop_resp = PROXY.request_filtered_principals([user_id], options={"return_id": True})
if not bop_resp["data"]: # User has been deleted
is_org_admin = user.get("UserMembership") == {"Name": "admin:org:all"}
user_name = user["Person"]["Credentials"]["Login"]
for ref in identifiers["Reference"]:
if ref["@entity-name"] == "Customer":
org_id = ref["#text"]
break
return {"user_id": user_id, "is_org_admin": is_org_admin, "username": user_name, "org_id": org_id}, True
return bop_resp["data"][0], False


def process_principal_data(user_data, is_deleted):
"""Process the principal data."""
if is_deleted:
process_principal_deletion(user_data)
else:
process_principal_edit(user_data)


def process_umb_event(frame, umb_client):
"""Process each umb frame."""
data_dict = xmltodict.parse(frame.body)
canonical_message = data_dict.get("CanonicalMessage")
if not canonical_message:
return
try:
user_data, is_deleted = retrieve_user_info(canonical_message)
except Exception as e: # Skip processing and leave the it to be processed later
logger.error("process_umb_event: Error retrieving user info: %s", str(e))
return

process_principal_data(user_data, is_deleted)

umb_client.ack(frame)


def process_principal_events_from_umb():
"""Process principals events from UMB."""
logger.info("process_tenant_principal_events: Start processing principal events from umb.")
try:
UMB_CLIENT.connect()
UMB_CLIENT.subscribe(QUEUE, {StompSpec.ACK_HEADER: StompSpec.ACK_CLIENT_INDIVIDUAL})
Expand All @@ -168,17 +225,6 @@ def clean_principals_via_umb():

while UMB_CLIENT.canRead(2): # Check if queue is empty, two sec timeout
frame = UMB_CLIENT.receiveFrame()
data_dict = xmltodict.parse(frame.body)
is_deactivate = is_umb_deactivate_msg(data_dict)
if not is_deactivate:
# Drop the message cause it is useless for us
UMB_CLIENT.ack(frame)
continue
principal_name, groups = clean_principal_umb(data_dict)
if not groups:
logger.info(f"Principal {principal_name} was not under any groups.")
for tenant, group_names in groups.items():
logger.info(f"Principal {principal_name} was under tenant {tenant} in groups: {group_names}")
UMB_CLIENT.ack(frame) # This will remove the message from the queue
process_umb_event(frame, UMB_CLIENT)
UMB_CLIENT.disconnect()
logger.info("clean_tenant_principals: Principal clean up finished.")
logger.info("process_tenant_principal_events: Principal event processing finished.")
2 changes: 1 addition & 1 deletion rbac/management/principal/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class Types(models.TextChoices):
cross_account = models.BooleanField(default=False)
type = models.CharField(null=False, default=Types.USER, choices=Types.choices, max_length=20)
service_account_id = models.TextField(null=True)
user_id = models.CharField(max_length=36, null=True)
user_id = models.CharField(max_length=256, null=True, db_index=True)

class Meta:
ordering = ["username"]
Expand Down
32 changes: 32 additions & 0 deletions rbac/management/principal/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""
Copyright 2019 Red Hat, Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""


def create_tenant_relationships(tenant):
"""Create relationships for tenant."""
pass


def create_user_relationships(principal, is_org_admin):
"""Create relationships for user."""
pass


def remove_user_relationships(tenant, groups, principal, is_org_admin):
"""Remove relationships for user."""
# TODO: consider (admin) default groups
pass
4 changes: 2 additions & 2 deletions rbac/management/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
from django.core.management import call_command
from management.health.healthcheck import redis_health
from management.principal.cleaner import (
clean_principals_via_umb,
clean_tenants_principals,
process_principal_events_from_umb,
)
from migration_tool.migrate import migrate_data

Expand All @@ -36,7 +36,7 @@ def principal_cleanup():
@shared_task
def principal_cleanup_via_umb():
"""Celery task to clean up principals no longer existing."""
clean_principals_via_umb()
process_principal_events_from_umb()


@shared_task
Expand Down
14 changes: 9 additions & 5 deletions rbac/migration_tool/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,17 @@ def migrate_users(tenant: Tenant, write_db: bool):
def migrate_users_for_groups(tenant: Tenant, write_db: bool):
"""Write users relationship to groups."""
relationships = []
for group in tenant.group_set.all():
# Explicitly create relationships for platform default group
user_set = (
tenant.principal_set.filter(cross_account=False) if group.platform_default else group.principals.all()
)
for group in tenant.group_set.exclude(platform_default=True):
user_set = group.principals.all()
for user in user_set:
relationships.append(create_relationship("group", str(group.uuid), "user", str(user.uuid), "member"))
# Explicitly create relationships for platform default group
group_default = tenant.group_set.filter(platform_default=True).first()
if not group_default: # Means it is not custom platform_default
group_default = Tenant.objects.get(tenant_name="public").group_set.get(platform_default=True)
user_set = tenant.principal_set.filter(cross_account=False)
for user in user_set:
relationships.append(create_relationship("group", str(group_default.uuid), "user", str(user.uuid), "member"))
output_relationships(relationships, write_db)


Expand Down
11 changes: 6 additions & 5 deletions rbac/rbac/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,12 @@
}

if settings.PRINCIPAL_CLEANUP_DELETION_ENABLED_UMB:
app.conf.beat_schedule["principal-cleanup-every-minute"] = {
"task": "management.tasks.principal_cleanup_via_umb",
"schedule": 60, # Every 60 second
"args": [],
}
if settings.UMB_JOB_ENABLED: # TODO: This is temp flag, remove it after populating user_id
app.conf.beat_schedule["principal-cleanup-every-minute"] = {
"task": "management.tasks.principal_cleanup_via_umb",
"schedule": 60, # Every 60 second
"args": [],
}
else:
app.conf.beat_schedule["principal-cleanup-every-sevenish-days"] = {
"task": "management.tasks.principal_cleanup",
Expand Down
1 change: 1 addition & 0 deletions rbac/rbac/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@

# Settings for enabling/disabling deletion in principal cleanup job via UMB
PRINCIPAL_CLEANUP_DELETION_ENABLED_UMB = ENVIRONMENT.bool("PRINCIPAL_CLEANUP_DELETION_ENABLED_UMB", default=False)
UMB_JOB_ENABLED = ENVIRONMENT.bool("UMB_JOB_ENABLED", default=True)
UMB_HOST = ENVIRONMENT.get_value("UMB_HOST", default="localhost")
UMB_PORT = ENVIRONMENT.get_value("UMB_PORT", default="61612")
# Service account name
Expand Down
Loading

0 comments on commit d7ff7d5

Please sign in to comment.