Skip to content

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_access is 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() raises 404, not 403, so a restricted-and-inaccessible codename is indistinguishable from one that does not exist. List endpoints (GET /distributions/) filter out restricted codenames silently via filter_accessible_codenames() instead of erroring on them.
  • API-token identities. check_distribution_access() accepts an optional role parameter. Without it, the role would be re-derived by looking up the caller's username in the users table — which has no row for an API-token identity (token:<name>), silently defaulting that identity to reader and 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 returns None (no filtering) when the caller has unrestricted access to every enabled machine — the common case, since machine_access is opt-in — and otherwise intersects any explicitly requested client_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_filters accepts any package name — unchanged behavior until a rule is added.
  • Katello-style evaluation. If any allow rules 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 matching deny rule always wins — a package matching both an allow rule and a deny rule is rejected.
  • Match types. Each rule is exact, glob (via fnmatch), or regex.
  • 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=True by default (lists what would be removed without removing anything), dry_run=False actually 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.