Skip to content

Umbryx — Access Control

Overview

Umbryx uses a two-level access model:

  1. Global role (User.role) - system-wide permissions
  2. Project role (ProjectMember.role) - permissions within a specific project

Global Roles

Role Description
platform_admin Super admin: can see all projects and bypasses project-level checks (each bypass is audit-logged)
user Regular user: can only see projects where they are a member
viewer Legacy read-only account: limited to GET requests at the middleware level. Not the same as the project-level viewer role — this is a global system role enforced at HTTP middleware. Avoid assigning to new users; prefer granting user with a project-level viewer membership instead.

Project Member Roles

There are 11 project-member roles (backend/app/core/enums.py::MemberRole). The full, authoritative source is backend/app/core/permissions.py (ROLE_PERMISSIONS); if this page and the code ever disagree, trust the code and update this page.

Role Description
owner Full access, member management, project deletion, and ownership transfer
admin Full operational access plus member management (except owner actions)
editor CRUD access to all project data, without member management
manager Broad operational access (hosts/creds/findings/notes/loot CRUD, network update, scans/playbooks run, infra read) without member management or exports — a lighter-weight editor
team_lead Like manager but read-only on network topology; can run scans, create and edit hosts/creds/findings/notes/loot
operator Operational actions: hosts, findings, activities, cred creation and limited deletes (hosts, loot)
analyst Read-heavy: full read across hosts/creds/findings/notes/loot/network/reports, plus create/update on findings and notes only — no write on hosts/creds
reviewer Findings-review focused: read findings/notes/reports/hosts/creds(metadata)/loot/network, reports.export, project.export, audit.read — no secrets, no write outside findings
viewer Read-only access to all project data, without secrets
observer Same shape as viewer but a narrower read set (no credentials access at all, not even redacted)
auditor Read access to reports, timeline, findings, hosts/creds(metadata)/loot/network, reports.export, and audit.read; no secrets and no editing

Permission Matrix (Key Permissions)

Permission owner admin editor manager team_lead operator analyst reviewer viewer observer auditor
project.read ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓
project.update ✓ ✓ — ✓ — — — — — — —
project.delete ✓ — — — — — — — — — —
project.manage_members ✓ ✓ — — — — — — — — —
project.export ✓ ✓ ✓ — — — — ✓ — — —
project.import ✓ ✓ — — — — — — — — —
project.transfer_ownership ✓ — — — — — — — — — —
hosts.read ✓ ✓ ✓ ✓ ✓ ✓ ✓ — ✓ ✓ ✓
hosts.create/update ✓ ✓ ✓ ✓ ✓ ✓ — — — — —
hosts.delete ✓ ✓ ✓ — — ✓ — — — — —
credentials.read ✓ ✓ ✓ ✓ ✓ ✓ ✓ — ✓ — —
credentials.read_secret ✓ ✓ ✓ — — ✓ — — — — —
credentials.create/update ✓ ✓ ✓ ✓ ✓ ✓ — — — — —
findings.read ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓
findings.create/update ✓ ✓ ✓ ✓ ✓ ✓ ✓ update only — — —
findings.delete ✓ ✓ ✓ — — — — — — — —
findings.approve — — — — — — — ✓ — — —
network.manage_nodes/links ✓ ✓ ✓ — — — — — — — —
network.update ✓ ✓ ✓ ✓ — — — — — — —
topology.apply ✓ ✓ ✓ — — — — — — — —
scans.run ✓ ✓ ✓ ✓ ✓ ✓ — — — — —
playbooks.run ✓ ✓ ✓ ✓ ✓ — — — — — —
audit.read — — — — — — — ✓ — — ✓
reports.export ✓ ✓ — — — — — ✓ — — ✓

This table is a summary of the most security-relevant permissions, not the complete matrix — the full, authoritative source is backend/app/core/permissions.py (ROLE_PERMISSIONS). If the two ever disagree, trust the code and update this table.

The security-relevant distinctions to remember: credentials.read_secret (owner/admin/editor/operator only) and findings.approve (reviewer only) — every other role sees redacted/read-only data. Verify this still holds before trusting this table if ROLE_PERMISSIONS has changed since.


Organization Roles and the Org ↔ Project Relationship

Umbryx actually has three role axes, not two — this section documents the third (OrganizationMember.role), added by the multi-tenancy work.

  1. Global role (User.role) — see above.
  2. Organization role (OrganizationMember.role) — administers one Organization: its registries (core/image_registries.py), its members, its teams. Reuses the exact same MemberRole vocabulary as project roles (owner/admin/editor/manager/team_lead/.../auditor) — same strings, but a separate membership table (organization_members, not project_members). An org role does not by itself carry any of the project-level permission strings from the matrix above; see the mapping below for how it actually reaches a project.
  3. Project role (ProjectMember.role) — see above; this is what ROLE_PERMISSIONS and the permission matrix are keyed on.

How an org role reaches a project

A Project optionally belongs to one Organization (Project.org_id, nullable). Attaching/ detaching is PATCH /api/projects/{pid} with {"org_id": "..."} (or {"detach_org": true} to clear it) — this requires org-admin authority (core/org_access.py::require_org_admin) over the org being attached, and over the org being detached from if the project already belonged to one. A plain project editor cannot hand their project's visibility to an org they don't administer.

Once a project has an org_id, core/access.py::_resolve_role() resolves an org member's effective role on that project, checked live on every request (no caching), in this order:

  1. An active direct ProjectMember row always wins if one exists (manual per-project grant).
  2. Org owner/admin → always resolves to project admin (full operational bypass), regardless of any team assignment — logged as ORG_ADMIN_BYPASS.
  3. Revoked membership blocks fallback. An inactive ProjectMember row (a deliberate removal from the project) stops the team and org-member fallbacks below — removing someone from a project revokes their access even while they remain in the owning organization. Only org owner/admin (step 2) keeps its org-wide reach.
  4. Team membership — if the project is attached to a Team (ProjectTeam) the user belongs to, their TeamMember.default_project_role (or .role) applies.
  5. Any other org membership (manager/team_lead/analyst/.../auditor — anything short of owner/admin) → falls back 1:1 to that org role as the effective project role. This is what gives every org member default visibility into their org's projects without a manual ProjectMember row.

GET /api/projects (core/access.py::get_user_member_pids) mirrors the same precedence for the project list — direct membership, any org membership (all of that org's projects), and team membership (via ProjectTeam) are all unioned.

Org role administration

Set Roles May…
ORG_ADMIN_ROLES owner, admin Administer org settings: registries, org profile, invite/remove members and teams, edit an existing member's role (PATCH /api/orgs/{id}/members/{uid})
ORG_USER_MANAGER_ROLES owner, admin, manager, team_lead Onboard brand-new platform accounts straight into the org (POST /api/orgs/{id}/users) without needing a platform_admin; see own org's audit trail for member/team lifecycle events

A caller may never grant (on add, on create, or on role-edit) an org role that outranks their own — core/org_access.py::org_role_rank/caller_org_rank, ceiling = owner(6) > admin(5) > manager(4) > team_lead(3) > everything else(1). Violating this is a 403, not a silent downgrade.

Dynamic enforcement, not token caching

The JWT access token only carries identity (sub, username) plus the global role claim as a convenience — every authenticated request re-fetches models.User from the DB (core/deps.py::get_current_user) and every project/org permission check (check_pid_access, is_org_admin, get_org_role, ...) queries the membership tables live, on every call. There is no separate permission cache to invalidate: a role downgrade, a member removal, or an account deactivation takes effect on the next request, not at token expiry — already the desired behavior, not something layered on top.

Audit trail

Org/team member add, remove, and role-change all call core/audit.py::log_action with ACT_MEMBER_ADD / ACT_MEMBER_REMOVE / ACT_MEMBER_ROLE_CHANGE, target_type = "org_member"/"team_member", project_id=None (these aren't project-scoped events), and detail carrying org_id/team_id plus the role(s) involved. GET /api/audit/log surfaces these to platform_admin always, and to an org's own ORG_USER_MANAGER_ROLES members scoped to their own org(s) — previously the endpoint's project-membership filter silently hid every org-scoped entry (project_id IS NULL) from everyone but a platform_admin.


OIDC / SSO Login (2026-08-18)

Org-scoped OpenID Connect authentication, in addition to (never replacing) password + TOTP-MFA login. Modeled after the same "abstraction + registry, no hand-typed endpoints" pattern used for firewall parsers and infra provider adapters — see backend/app/core/oidc/.

For the operational how-to (registering the platform as a client at Keycloak/Okta/Azure AD/Google Workspace, PUBLIC_BASE_URL, JIT provisioning, role mapping, MFA composition, enforcing SSO, troubleshooting) see docs/modules/OIDC_SSO_SETUP.md. This section documents the design; that one is the step-by-step guide.

  • Data model: OIDCProvider (org-scoped, client_secret via core/crypto.py::encrypt_str — never plaintext), OIDCIdentity (durable (provider_id, subject) → user_id link), OIDCAuthState (one row per in-flight login, holding PKCE code_verifier + nonce — deleted on use, so a captured callback URL can't be replayed). Authorization/token/userinfo/JWKS endpoints are always populated by discovery ({issuer}/.well-known/openid-configuration) — an admin can never hand-type them.
  • Flow: Authorization Code + PKCE (S256) only, full id_token validation (JWKS signature, iss, aud, exp, nonce) — see core/oidc/client.py::validate_id_token. redirect_uri is always built server-side from PUBLIC_BASE_URL/the request's own host, never accepted from the client — this is what closes the open-redirect angle on that parameter entirely, rather than an allowlist check. Every issuer/token/JWKS URL goes through core/ssrf_guard.py before any outbound request.
  • Account linking is never automatic on an email match — routers/oidc_auth.py's callback hands back a short-lived, single-purpose oidc_link_pending token (same type-claim discipline as mfa_pending, see below) that /api/auth/oidc/confirm-link will only redeem given that account's actual password. A same/spoofed email at a less-trusted org IdP can never take over an existing password-based account this way.
  • JIT provisioning (opt-in per provider) creates a global-role="user" account — never platform_admin — plus an OrganizationMember row whose role comes from role_mapping (an IdP group claim value → MemberRole) or default_role if unmapped; an unrecognized group is never silently granted elevated access.
  • MFA composition: whether platform TOTP-MFA is still required after OIDC is a per-provider toggle (require_platform_mfa), not hardcoded either way. When on, a validated id_token produces only the same mfa_pending token password login already issues (core/totp.py) — no cookie, no access token — until /api/auth/mfa/verify (unchanged) confirms a TOTP code. The id_token being valid is deliberately treated exactly like a correct password on its own: neither is "logged in" by itself. tests/test_oidc.py::TestOIDCLoginFlow::test_mfa_required_on_top_of_oidc_not_a_full_session covers this path.
  • "Obligatory OIDC" (OIDCProvider.enforced) disables password login for that org's members (routers/auth.py::_org_enforces_oidc; platform_admin is always exempt). Can only be switched on after last_test_login_at is set — which only happens on a real successful callback through that exact provider — so a broken config can't be flipped to "mandatory" and lock out the whole org at once.
  • Plan gating: creating a new provider requires PlanFeature.SSO on the org's plan (core/plan.py::has_feature — reused as-is, not a second enforcement path); editing/deleting an existing one is always allowed so a plan downgrade can't strand an unmanageable config.

API

Member Management

GET    /api/projects/{pid}/members              - list members
POST   /api/projects/{pid}/members              - add a member
PATCH  /api/projects/{pid}/members/{uid}        - change a role
DELETE /api/projects/{pid}/members/{uid}        - remove a member
POST   /api/projects/{pid}/transfer-ownership   - transfer ownership
GET    /api/projects/{pid}/permissions/me       - get my project permissions

Add a member:

{ "user_id": "u...", "role": "editor" }

Change a role:

{ "role": "viewer" }

Current user permissions:

{
  "project_id": "p...",
  "role": "editor",
  "permissions": ["project.read", "hosts.read", "hosts.create", ...],
  "is_super_admin": false
}


Security Rules

  • Intended design: a user cannot access objects from another project by direct ID — every router is expected to call check_pid_access/check_object_access (core/access.py) using the object's own pid/project_id field, never a value taken from the request body/query.
  • GET /api/projects returns only projects where the user is a member (directly, via a team, or via their organization)
  • credentials.secret is returned as an empty string without the credentials.read_secret permission
  • viewing decrypted credential secrets is audit-logged in the project timeline
  • viewing confidential note content is audit-logged in the project timeline
  • viewing sensitive text loot or downloading protected uploaded files is audit-logged in the project timeline
  • Export requires project.export; import requires project.import
  • WebSocket (/ws/{pid}) authenticates from the session cookie only (query-string tokens are not accepted) and validates direct project membership before connecting — an org member without a direct ProjectMember row has REST access but cannot open the project's WS room
  • When a project is created, the creator automatically becomes owner
  • When a new project is imported, the current user becomes owner
  • The last owner of a project cannot be removed
  • Global platform_admin bypasses all project-level checks (each bypass is audit-logged)
  • POST /api/projects/{pid}/purge requires the same authority as project deletion and also removes project upload files from disk

Existing Data Migration

On startup, the backend automatically: 1. Creates the project_members table 2. Assigns the first admin user as owner for every project that does not yet have one

Indexes:

  • idx_pm_project_id - fast project lookup
  • idx_pm_user_id - fast user lookup
  • idx_pm_project_user - unique constraint

Manual Testing

# 1. Create user bob
curl -X POST /api/admin/users \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"username":"bob","password":"pass","role":"user"}'

# 2. Bob does not see admin projects
curl /api/projects -H "Authorization: Bearer $BOB_TOKEN"
# → []

# 3. Admin adds Bob as viewer
curl -X POST /api/projects/{pid}/members \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"user_id":"<bob_id>","role":"viewer"}'

# 4. Bob sees the project but cannot create a host
curl -X POST /api/hosts \
  -H "Authorization: Bearer $BOB_TOKEN" \
  -d '{"pid":"...","ip":"10.0.0.1",...}'
# → 403 Insufficient permissions

# 5. Bob as viewer cannot see secrets
curl /api/creds?pid=... -H "Authorization: Bearer $BOB_TOKEN"
# → secret: ""

# 6. Check permissions
curl /api/projects/{pid}/permissions/me \
  -H "Authorization: Bearer $BOB_TOKEN"