## Feedback on spiceDB approach The feedback on the SpiceDB approach received from an enterprise user of Pulp was: 1. This sounds like I have to run spiceDB also. I don't want to run spiceDB. 2. Having Pulp define roles externally sounds like too much work. 3. Other apps in the enterprise handle AD groups -> Roles mapping internally. The example given was Jenkins (see below). ## The Jenkins Example ### Jenkins Objects Roles (Defined in Jenkins): Global Roles: Apply to all pipelines (e.g., viewer = read-only). Project Roles: Apply to specific pipelines/folders (e.g., foo-maintainer for pipeline foo). Pipelines/Jobs: Individual tasks (e.g., foo, deploy-app). Folders (Optional): Group pipelines (e.g., finance/*) for hierarchical RBAC. ### Permissions Actions: Read, Build, Configure, Administer. Scope: Assigned to roles (e.g., foo-maintainer grants Build + Configure for foo). ## Jenkins example 1: Roles for all pipelines Use Case: Assign a role (e.g., viewer) that grants read-only access to all jobs. Steps: Go to Manage Jenkins → Manage and Assign Roles → Manage Roles. Under Global Roles, add a role (e.g., viewer) and tick permissions: Overall/Read Job/Read View/Read (No Build or Configure permissions) Global Role Example (Example: Viewer role) Under Assign Roles, map an AD group (e.g., jenkins-viewers) to this role: In the Global roles section, add jenkins-viewers under viewer. Now, any user in the AD group jenkins-viewers can view all pipelines but cannot modify or run them. ## Jenkins example 2: Role for a specific pipeline Use Case: Grant a group (e.g., foo-devs) edit/run access only to pipeline foo. Steps: Go to Manage Jenkins → Manage and Assign Roles → Manage Roles. Under Project Roles, add a role named foo-maintainer: Set a Pattern matching the job name (e.g., ^foo$ for exact match). (Regex can also match folders, e.g., ^dev/foo.*) Assign permissions: Job/Read Job/Build Job/Configure (allows editing) Job/Cancel Project Role Example (Example: foo-maintainer role) Under Assign Roles, map the AD group foo-devs to this role: In the Item roles section, add foo-devs under foo-maintainer. Now, users in foo-devs can edit, run, and cancel the foo pipeline but cannot touch other jobs. ## High Level Idea * Keep the notion of "domain level" Roles and "within a domain" (object-level) Roles * Extend the Roles API so that in addition to defining one you can * add one or more externally defined group names (or identifiers) which map to this Role * add the ability for that Role to provide permissions for a specific objects via PRN * add the ability to have wildcard matching with PRN-regex * Allow Pulp to be configured to know how to interpret the groups a user is a member of via request headers ## How different is Pulp's current RBAC from this? Similar: * We have [a Roles API](https://pulpproject.org/pulpcore/restapi/#tag/Roles/operation/roles_create). * We have a notion of permissions, e.g `sync_rpmrepository` [here](https://github.com/pulp/pulp_rpm/blob/65b3f356361389d7fd33e6bb8cc245be040102da/pulp_rpm/app/models/repository.py#L377C15-L377C33). * We have viewsets with drf-access-policy which can provide the N permission checks required for an action We're missing: * The ability to add externally defined groups to a Role. Without this Pulp can't map a user defined in AD to one or more Pulp Roles * The ability to mark which portion of a SAML payload is where Pulp should find the group listing. * The ability to scope a Role to specific objects, e.g. PRN or PRN-regex ## Permissions example Look at the existing enforcement from [here](https://github.com/pulp/pulp_rpm/blob/a278368bab8529071a9b63305b892c9a9d0c9089/pulp_rpm/app/viewsets/repository.py#L107) on the pulp_rpm `/sync/` endpoint: ``` "action": ["sync"], "principal": "authenticated", "effect": "allow", "condition": [ "has_model_or_domain_or_obj_perms:rpm.sync_rpmrepository", "has_model_or_domain_or_obj_perms:rpm.view_rpmrepository", "has_remote_param_model_or_domain_or_obj_perms:rpm.view_rpmremote", ], }, ``` Deep down, the permission checking uses code [like this](https://github.com/pulp/pulpcore/blob/c99c2c2937443d10d0db574465088590c5dbe0bd/pulpcore/app/global_access_conditions.py#L191): `request.user.has_perm(permission, remote)` Consider then this `Role` and `RolePermission` alternative: ``` from django.db import models from django.contrib.auth.models import User, Permission class Role(models.Model): name = models.CharField(max_length=100) saml_groups = models.JSONField() # Stores SAML group names that map to this role global_permissions = models.ManyToManyField(Permission, blank=True) class RolePermission(models.Model): role = models.ForeignKey(Role, on_delete=models.CASCADE) permission = models.ForeignKey(Permission, on_delete=models.CASCADE) object_id = models.PositiveIntegerField(blank=True, null=True) # For object-level scoping content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, blank=True, null=True) ``` With this `Permission` is stored in Django in the DB in the same way and checked in the same way `request.user.has_perm(permission, remote)`. At that point you need to define a custom `AuthenticationBackend` for example: ``` from django.contrib.auth.backends import BaseBackend from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from your_app.models import Role, RolePermission class SAMLRoleBackend(BaseBackend): def get_user_permissions(self, user, obj=None): if not user.is_authenticated: return set() # Get SAML groups (from request.session, user.profile, or wherever you store them) saml_groups = user.saml_groups # Adjust based on how you store SAML groups # Find roles matching the user's SAML groups roles = Role.objects.filter(saml_groups__overlap=saml_groups) permissions = set() # Add global permissions from roles for role in roles: permissions.update(role.global_permissions.all()) # Add object-level permissions (if obj is provided) if obj is not None: content_type = ContentType.objects.get_for_model(obj) role_perms = RolePermission.objects.filter( role__in=roles, permission__content_type=content_type, object_id=obj.pk ) permissions.update(rp.permission for rp in role_perms) return permissions def has_perm(self, user, perm, obj=None): if not user.is_authenticated: return False # Get all permissions for the user (global + object-specific) user_perms = self.get_user_permissions(user, obj) # Check if the requested permission is in the set perm_parts = perm.split('.') required_perm = f"{perm_parts[-2]}.{perm_parts[-1]}" # Format: 'app_label.codename' return any(p.codename == perm_parts[-1] and p.content_type.app_label == perm_parts[-2] for p in user_perms) ``` Define Django to use it: ``` AUTHENTICATION_BACKENDS = [ 'your_app.backends.SAMLRoleBackend', # Your custom backend 'django.contrib.auth.backends.ModelBackend', # Fallback (optional) ] ``` And someone have the saml role data cached on the callback, e.g. ``` def saml_login_callback(request): saml_groups = request.session.get('saml_attributes', {}).get('groups', []) request.user.saml_groups = saml_groups # Or store in a related model request.user.save() ```