Umbryx — Access Control¶
Overview¶
Umbryx uses a two-level access model:
- Global role (
User.role) - system-wide permissions - 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.
- Global role (
User.role) — see above. - Organization role (
OrganizationMember.role) — administers oneOrganization: its registries (core/image_registries.py), its members, its teams. Reuses the exact sameMemberRolevocabulary as project roles (owner/admin/editor/manager/team_lead/.../auditor) — same strings, but a separate membership table (organization_members, notproject_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. - Project role (
ProjectMember.role) — see above; this is whatROLE_PERMISSIONSand 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:
- An active direct
ProjectMemberrow always wins if one exists (manual per-project grant). - Org owner/admin → always resolves to project
admin(full operational bypass), regardless of any team assignment — logged asORG_ADMIN_BYPASS. - Revoked membership blocks fallback. An inactive
ProjectMemberrow (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. - Team membership — if the project is attached to a
Team(ProjectTeam) the user belongs to, theirTeamMember.default_project_role(or.role) applies. - 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
ProjectMemberrow.
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_secretviacore/crypto.py::encrypt_str— never plaintext),OIDCIdentity(durable(provider_id, subject) → user_idlink),OIDCAuthState(one row per in-flight login, holding PKCEcode_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) — seecore/oidc/client.py::validate_id_token.redirect_uriis always built server-side fromPUBLIC_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 throughcore/ssrf_guard.pybefore any outbound request. - Account linking is never automatic on an email match —
routers/oidc_auth.py's callback hands back a short-lived, single-purposeoidc_link_pendingtoken (sametype-claim discipline asmfa_pending, see below) that/api/auth/oidc/confirm-linkwill 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 — neverplatform_admin— plus anOrganizationMemberrow whose role comes fromrole_mapping(an IdP group claim value →MemberRole) ordefault_roleif 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 samemfa_pendingtoken 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_sessioncovers this path. - "Obligatory OIDC" (
OIDCProvider.enforced) disables password login for that org's members (routers/auth.py::_org_enforces_oidc;platform_adminis always exempt). Can only be switched on afterlast_test_login_atis 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.SSOon 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:
Change a role:
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 ownpid/project_idfield, never a value taken from the request body/query. GET /api/projectsreturns only projects where the user is a member (directly, via a team, or via their organization)credentials.secretis returned as an empty string without thecredentials.read_secretpermission- 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 requiresproject.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 directProjectMemberrow 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
ownerof a project cannot be removed - Global
platform_adminbypasses all project-level checks (each bypass is audit-logged) POST /api/projects/{pid}/purgerequires 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 lookupidx_pm_user_id- fast user lookupidx_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"