owned this note
owned this note
Published
Linked with GitHub
## 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()
```