The RBAC model¶
Repod controls access through three independent layers: a global role (what a user can do), a distribution scope (which repositories a user can act on), and a machine scope (which inventory machines a user can act on). This page explains how the three layers combine, and how each of the two scoping layers — plus the related content-filter gate on package names — actually works. For the base role definitions and the full permission matrix, see Roles & permissions; this page does not repeat that matrix.
Why three layers¶
A 5-role global RBAC (reader < uploader/auditor < maintainer <
admin) answers "can this user upload packages at all?" It cannot answer
"can this user upload to the finance-internal distribution specifically?"
or "can this user see the CVE posture of machines belonging to another
team?" Those are scoping questions, not capability questions, and Repod
answers them with two separate opt-in layers on top of the global role:
distribution_access— restricts which distribution codenames (jammy,almalinux9, …) a user can reach.machine_access— restricts which inventory machines a user can reach.
A third mechanism, content filters, is not RBAC at all — it is a
package-name allow/deny policy per distribution, evaluated independently of
who is uploading. It is documented here because it sits at the same entry
points as distribution_access and is easy to confuse with it.
How the layers combine¶
Distribution scope and machine scope are combined with the global role
using AND, never OR. The global role dependency (get_uploader_user,
get_maintainer_user, get_admin_user, get_auditor_user) still gates
the action — a reader cannot upload a package no matter what
distribution_access says. The scoping layers add a restriction on top of
an already-permitted action; they never grant a capability the global role
doesn't already have.
flowchart TD
Req["Request: install a package\non machine M in distribution D"] --> Role{"Global role check\nget_uploader_user / get_maintainer_user / …"}
Role -->|"role insufficient"| R403["403 Forbidden"]
Role -->|"role sufficient"| Admin{"role == admin?"}
Admin -->|"yes"| Allow["Proceed\n(admin bypasses both scoping layers)"]
Admin -->|"no"| Dist{"distribution_access:\ncheck_distribution_access(user, D)"}
Dist -->|"denied"| D404["404 Not Found\n(anti-leak: same as D not existing)"]
Dist -->|"allowed"| Mach{"machine_access:\ncheck_machine_access(user, M)"}
Mach -->|"denied"| M404["404 Not Found\n(anti-leak: same as M not existing)"]
Mach -->|"allowed"| Allow
Both scoping checks default to open when no rule has ever been created
for the resource in question — a fresh installation, or an existing one
that has never configured distribution_access/machine_access, behaves
exactly as if these layers did not exist. They only start restricting
access the moment an administrator adds the first row for a given
distribution or machine. This is a deliberate compatibility guarantee: no
upgrade to a version that ships these layers can lock an existing
deployment out of its own repositories or machines.
admin always bypasses both distribution_access and machine_access
unconditionally. This is intentional, not an oversight: without a
guaranteed bypass, a distribution or machine could end up with no
authorized user able to manage it (for example, a role/group referenced by
an access rule is later deleted).
Distribution-level scope (distribution_access)¶
distribution_access restricts which distribution codenames a user can
reach. Distributions have no database row of their own — codenames are a
fixed, hardcoded list per format (services/distributions_apt.py,
distributions_rpm.py, distributions_apk.py) — so access rules live in
their own table, one row per (codename, principal_type, principal_id),
where principal_type is role or group and principal_id references
a custom role or a group.
- Open by default. A codename with zero rows in
distribution_accessis reachable by any authenticated user whose global role already permits the action. Adding the first row for a codename is what turns on the restriction. - Combining multiple rules. All rows for a codename combine as a union — a role match or a group match is sufficient; there's no intersection semantics here.
- 404, not 403, on denial.
ensure_distribution_access()raises404, not403, so a restricted-and-inaccessible codename is indistinguishable from one that does not exist. List endpoints (GET /distributions/) filter out restricted codenames silently viafilter_accessible_codenames()instead of erroring on them. - API-token identities.
check_distribution_access()accepts an optionalroleparameter. Without it, the role would be re-derived by looking up the caller's username in theuserstable — which has no row for an API-token identity (token:<name>), silently defaulting that identity toreaderand losing its real granted role. Callers that already know the resolved role (API-token authentication paths) pass it explicitly; JWT-authenticated call sites are unaffected.
Enforced on: GET/POST/DELETE /distributions/{codename}/access (ACL
management itself, admin-only), GET /distributions/{codename}/packages,
POST /distributions/promote and /distributions/migrate (checked on both
from_dist and to_dist), POST /upload/ and /upload/stream, and the
import endpoints (POST /import/fetch, /import/batch, plus the
distribution filter on GET /import/search?distro=).
Package downloads are gated too, with one documented exception
RPM and APK downloads are fully closed by distribution_access
through nginx auth_request, because each format's generated
per-distribution index is self-contained — a DNF/apk client never
needs to reach outside its own distribution's tree. APT's shared
pool/ directory is only partially closed: a .deb that has ever
been promoted into a distribution via reprepro copy stays reachable
from pool/ afterward regardless of which distribution it is
currently recorded against, because reprepro's promote operation
leaves the file present in both the source and destination
distributions with no per-distribution storage split. A package
uploaded or imported directly into a restricted distribution and
never promoted is fully protected.
Machine-level scope (machine_access)¶
machine_access restricts which inventory machines a user can reach. It
extends the same conceptual model as distribution_access (open by
default, admin bypass, AND with the global role, 404 anti-leak) to a second
resource type, using its own table rather than reusing
distribution_access's schema.
machine_access has two independent principal axes:
| Axis | Values | Meaning |
|---|---|---|
user_principal_type |
role | group |
Who the rule grants access to |
machine_principal_type |
tag | client |
Which machine(s) the rule applies to |
There is no dedicated "machine group" entity — a machine group is simply
an inventory tag (inventory_clients.tags, free-form). A client-type
rule references one machine's id directly.
- Client override replaces tag rules, it never merges with them. If any rule directly references a machine's id, that rule set is the entire effective rule set for that machine — the machine's tag-derived rules are ignored completely, not combined. Without a client-level override, all rules from every tag the machine carries combine as a union: any one matching role or group grant is sufficient. This union choice is deliberate — an intersection would mean tagging a machine with a second, unrelated tag could silently revoke an existing team's access, a worse surprise than the union's tradeoff (that a multi-purpose tag widens access to a machine).
resolve_scope_for_aggregates()computes the effective scope for aggregate/summary endpoints (fleet-wide inventory, compliance, executive dashboards). It returnsNone(no filtering) when the caller has unrestricted access to every enabled machine — the common case, sincemachine_accessis opt-in — and otherwise intersects any explicitly requestedclient_ids/tags with what the caller can actually see. A caller can never widen their effective scope by passing a broader request; the intersection only narrows it.
Enforced on: every client-scoped endpoint in the inventory, drift, and
app-dependency routers — SSH fingerprint management, scan
trigger/status/cancel, packages, updates, CVE results, compliance, and
container endpoints — plus list endpoints (GET /inventory/clients,
silently filtered rather than blocked), POST /inventory/scan-all (403 if
the caller cannot see the whole active fleet, since the underlying scan
runs unconditionally and a partial scan cannot be silently substituted),
and install-job creation/read/list/confirm/cancel (a job with any
inaccessible target machine is rejected, or 404s on read).
Content filters (content_filters)¶
Content filters are package-name allow/deny rules per distribution — not an access-control layer on users, but a policy gate on package names that any permitted upload, import, or promotion must pass. They exist independently of who is performing the action.
- Open by default. A distribution with zero rules in
content_filtersaccepts any package name — unchanged behavior until a rule is added. - Katello-style evaluation. If any
allowrules exist for the distribution, the package name must match at least one of them, or it is rejected outright. Whether or not an allow rule matched, any matchingdenyrule always wins — a package matching both an allow rule and a deny rule is rejected. - Match types. Each rule is
exact,glob(viafnmatch), orregex. - Applied at entry, before any disk write. The filter is evaluated
before the package's bytes ever reach
pool/or the manifest index — at upload, at import (before the artifact is even downloaded, since the name/version are already known from the source index), and at promotion/migration between distributions (gated on the destination distribution). - Never retroactive by itself. Adding or changing a rule has no effect
on packages already present.
evaluate_existing_distribution()can explicitly sweep a single distribution's current content against its active rules —dry_run=Trueby default (lists what would be removed without removing anything),dry_run=Falseactually removes non-compliant packages, scoped to that one distribution only.
Content filters are a distinct mechanism from the CVE review queue
(pending_review): a filter is a binary policy decision made
automatically at the entry point, with no human-approval step, whereas
pending_review holds a package that otherwise passed validation for an
explicit accept/reject decision.
Summary¶
| Layer | Resource | Table | Default | Bypass | Denial response |
|---|---|---|---|---|---|
| Global role | Action | users.role |
N/A — always enforced | N/A | 403 Forbidden |
distribution_access |
Distribution codename | distribution_access |
Open (no rows = open) | admin |
404 Not Found |
machine_access |
Inventory machine | machine_access |
Open (no rows = open) | admin |
404 Not Found |
content_filters |
Package name | content_filters |
Open (no rows = open) | none (not a user-scoping layer) | Rejected at entry (no HTTP 404 semantics — it's a validation failure, not an access decision) |
Every layer beyond the base role is opt-in: none of them can regress an existing deployment's access, because none of them do anything at all until an administrator explicitly creates the first rule for a given distribution, machine, or package-name pattern.