Users & teams
The portal models authorization as one Organization, many Teams, and three Roles. Every user belongs to one or more teams, and projects belong to teams. There is exactly one organization per deployment.
Super-admins setting up the deployment; team admins managing their team's membership.
The model
Organization (one per deployment)
├── Super Admin — system-wide admin (you, after install.sh)
├── Team A
│ ├── Team Admin — manages team settings + members
│ └── Developer — runs scans, triages findings
└── Team B
└── ...
- Organization — the boundary of the deployment. Super-admins are scoped to the org.
- Team — projects, scans, and findings live inside a team.
- User — a person with an email + password (or OAuth identity for the demo SaaS).
Roles
| Role | Scope | Capabilities |
|---|---|---|
super_admin | Org-wide | All admin screens (/admin/**). Can create / delete teams. Can edit any project. Can read every audit log. |
team_admin | Per team | Manage team membership and team settings. Edit any project owned by the team. Dispose approvals. Manage API keys for the team. |
developer | Per team | Read team projects. Create / edit projects. Run and cancel scans. Triage findings (VEX state). Cannot manage members or settings. |
viewer | Per team | Read-only across the portal: projects, components, vulnerabilities, licenses, obligations, SBOMs, reports, search, the approval queue, and license policy. Cannot run scans, edit anything, or reach the source tree, credentials, or the audit log. |
Roles are additive across teams: a user can be team_admin in one team and developer in another. The role is evaluated per project based on the project's owning team. Every write action checks rank rather than an exact role match, so viewer is the floor below developer rather than a separate permission set.
super_admin is not a per-team role; it grants org-wide access regardless of team membership.
The Users page
The /admin/users page lists every account in the deployment with role badges, activation status, last-sign-in timestamp, and team membership counts. Search by email or name; filter by role and status.

The companion /admin/teams page enumerates teams and the projects + members each owns:

Adding people in bulk
/admin/users → Import takes a CSV and adds everyone in it, reporting what happened to each row. The first line names the columns:
email,full_name,team_id,role
ada@example.com,Ada Lovelace,3f1c...,developer
grace@example.com,Grace Hopper,3f1c...,viewer
Only email is required. role must be team_admin, developer or
viewer; a row that leaves it empty follows the deployment's
DEFAULT_MEMBER_ROLE, which is developer unless you set it. super_admin
cannot be granted here.
Export writes the same columns, so the file you download can be edited and fed straight back in. It carries no passwords in either direction.
A password column is accepted but rarely wanted. On a deployment where people sign in through your identity provider, leave it out: the account is created with no password set, so it cannot be signed into until somebody sets one through the reset flow. A password that is present is held to the same policy as one chosen at signup.
Rows are independent. One address that already has an account does not stop the rows around it, and the result table lists every row, including the ones that worked, so you never have to count what is missing.
Deactivating in bulk works the same way and keeps the same protections: you cannot deactivate your own account or the last active administrator, and either arriving in a list comes back as one failed row rather than refusing the batch. Somebody already inactive is reported as unchanged. Deactivating revokes their live sessions, and it stops any API key they issued unless that key belongs to a service account.
API key authentication checks that the issuer is still active. Deactivating somebody therefore stops the pipelines using keys they issued. Move those keys to a service account before the person leaves.
Onboarding a new user
In this release the portal does not send invitation emails. New users join by self-registering at /register with their corporate email; the password policy is enforced at registration (≥ 12 chars, bcrypt cost 12, no NIST-banned passwords).
After they register, a super_admin adds them to the right team and assigns the role:
- Ask the user to register at
/register. - Once they appear under /admin/users, open the user drawer.
- Use Add to team (or the team's Members → Add member flow) to grant team membership at the chosen role.
Onboarding teammates
The portal does not send invitation emails in this release. The flow is:
- Admin creates the team at
/admin/teams → New team. (No need to copy the team UUID for the UI flow below — the admin matches teammates by email, not by team id. Scripted mass onboarding is the only path that needs the id; seeGET /v1/admin/teamsand the bulk recipe at the bottom of this section.) - Teammate self-registers at
https://<your-host>/register. - Admin opens the teammate's row at
/admin/users → <user>, drawer → Memberships → Add to team, picks the team and a role (developeris the safe default).
Result: the teammate now sees the team's projects on next login.
Mass onboarding can be scripted via
POST /v1/admin/teams/{team_id}/members {user_id, role} once each
teammate has registered.
First sign-in through your identity provider
When the portal is pointed at your own identity provider, somebody who has never signed in before is refused by default rather than given an account. Everybody in the company can authenticate against the directory, and only some of them are meant to have a portal account, so the roster is one you maintain.
Set AUTH_AUTO_REGISTER=true to admit them instead. What they get on arrival:
- An account, and a personal team of their own.
- The grade
OIDC_GROUP_ROLE_MAPnames for their groups, if you mapped any. - If you mapped groups and theirs match none, the lowest grade, because a deployment that has mapped its groups has said what matching none means.
- With no group map at all,
DEFAULT_MEMBER_ROLE, the same setting a bulk import follows.
AUTH_AUTO_REGISTER=false closes the provider door only. The sign-up form at
/register is a second one, and it is open by default: somebody signs up
under their work address, signs in through your provider, and the callback
links the provider identity to the account they just made for themselves.
They end up holding exactly the account the first setting was withholding.
Set AUTH_SELF_REGISTRATION=false as well. Both off means the roster is the
one your administrators maintain.
The refusal is deliberately indistinguishable from any other failed sign-in. Anyone in the directory can reach the login page, so a message saying "you authenticated but have no account" would let them check which addresses are registered.
The hosted providers (GitHub, Google) are unaffected and keep creating an account on first sign-in, which is what a signup is.
When a change to somebody's access takes effect
Immediately, unless you have asked otherwise. Every authenticated request reads the user and their team memberships, so a demotion, a deactivation or a removal from a team is in force on the next request.
PERMISSION_CACHE_TTL_SECONDS trades some of that for fewer database reads. It
is off by default. Whatever you set it to is the longest a revocation can go
unfelt: for up to that many seconds, a worker may still be answering with the
grade somebody had before you changed it.
A change made through the portal does drop the cached answer, but only in the worker that handled the request. The shipped deployments run four workers (Docker Compose) or two pods of four (Helm), so most requests after a demotion still land on a worker holding the old answer. Treat that drop as an optimisation, not a second guarantee: the lifetime is the only number that describes every worker.
Values above 300 seconds are clamped to 300. Reach for the database connection
pool first (DB_POOL_SIZE, DB_MAX_OVERFLOW); this setting is worth turning
on when you have measured that permission reads are the cost, not before.
When it is worth turning on
Turn it on only when all three of these hold at the same time. If any one of
them is not true, leave it at 0.
- The authenticated-read p95 is over your target.
- The connection pool is already sized to this deployment (
DB_POOL_SIZE,DB_MAX_OVERFLOW); see the connection budget guidance in.env.example. - Your organisation has accepted the revocation-delay ceiling above as policy, not as an accident.
And when all three hold, the ceiling on what it buys is modest. An authenticated read costs 5 SQL statements measured today, one query per request rebuilding the signed-in user's roles and team memberships (down from 6 before a prior optimisation removed a redundant statement from that same rebuild). This cache can remove at most that one remaining statement, roughly 20% of the request's SQL, in exchange for the revocation-delay window above. Weigh that fifth against the ceiling before you turn it on.
A demoted person can still read. A deactivated one is supposed to be gone, and
with a lifetime set they are not gone everywhere until it expires. If you need
a revocation to take effect the moment you make it, leave this at 0. That is
the only setting under which "deactivated" and "cannot reach anything" are the
same instant.
Adding an existing user to a team
Users can belong to many teams. To add an existing user:
- /admin/teams (super-admin) or Team settings → Members (team admin).
- Add member → search by email → choose role.
The user is added immediately; no email confirmation step is sent (they already have an account).
Changing a user's role
The drawer at /admin/users → user exposes a Role dropdown plus a Memberships section. A user can hold a different role in each team they belong to (team_admin in team A, developer in team B); the Memberships list shows every assignment and edits them in place. The role dropdown sets the role for the team selected in the Memberships list (or the user's global role when promoting to super_admin).
- /admin/users → user → Role.
- Choose the new role → submit.
The audit log records the change as a users write with the role diff under diff (the audit row's target_table is users).
Removing a user from a team
- Team settings → Members → user → Remove.
The user loses access to the team's projects but their account remains. To deactivate the account entirely, see deactivation.
Last-super-admin protection
The portal refuses to demote or deactivate the last active super_admin in the organization. The pre-flight check runs inside a SELECT … FOR UPDATE transaction, so concurrent demote attempts are serialized rather than racing. If you try, the API returns:
{
"type": "about:blank",
"title": "Last Super Admin Protected",
"status": 422,
"detail": "At least one active super_admin must remain in the organization.",
"instance": "/v1/admin/users/01H…/role",
"last_super_admin_protected": true
}
The last_super_admin_protected: true extension lets clients distinguish this guard from generic 422 validation failures.
To replace the last super-admin:
- Promote a second user to
super_adminfirst. - Then demote / deactivate the original.
The guard is enforced in two layers:
- API layer — a
SELECT … FOR UPDATErow-locked count insideadmin_user_servicerejects the demote / deactivate before commit. - DB layer — a PostgreSQL trigger (
trg_last_super_admin, migration0013) raisesSQLSTATE 23514for anyUPDATE/DELETEon theuserstable that would leave zero active super-admins, including directpsqlwrites that bypass the API. The samelast_super_admin_protectedProblem Details extension is surfaced regardless of which layer caught the bypass.
Recovering a deactivated super-admin
If the last super-admin row gets flipped to is_active=false despite the last-super-admin protection — for example, an integration test against the deployment's database tripped deactivate_user, or another super-admin demoted you before promoting a replacement — re-run the same bootstrap script scripts/install.sh used at install time. It detects the existing row and lifts is_active back to true without touching the stored password.
docker-compose -f docker-compose.yml exec -T \
-e ADMIN_EMAIL="admin@example.com" \
-e ADMIN_PASSWORD="<existing password — 12+ chars>" \
backend python -m scripts.create_super_admin
What the script does, in order:
- Looks up the row by
ADMIN_EMAIL. - If the row exists and is a
super_adminand is inactive → flipsis_activeback totrue, commits, and printssuper admin <email> reactivated. The stored password hash is not rewritten (a re-run stays non-destructive when you simply forgot the row was disabled). - If the row exists and is already active → prints
super admin <email> already exists — noopand exits 0. - If the row exists but is not a
super_admin→ prints an error and exits non-zero. Promote or replace the row manually before re-running. - If no row matches → creates a fresh super-admin with the supplied password.
The ADMIN_PASSWORD value is only used when the row is being created. On the reactivation path the password hash stays as it was — supply the current password, not a new one. If you have lost the password too, follow Reset your password after reactivation lifts is_active, or use the operator-side /admin/users/{id}/password-reset endpoint from a second super-admin account.
Reactivating the last admin from the UI would be a bootstrap paradox — there is no admin available to click the button. The script runs inside the backend container with database credentials, so it is the safe recovery hatch even when no super-admin can sign in. The action is idempotent: running it on an already-active row is a no-op.
Helping somebody who cannot sign in
The user drawer at /admin/users → user carries two actions for this, and they answer different problems. Both are recorded in the audit log.
Sign-in is refused before the password is even checked
Failed attempts are counted per email address, so an address can be refused because of guesses that were never the account holder's. Unlock sign-in clears that count.
- /admin/users → user → Unlock sign-in.
- Confirm.
Finishing a password reset clears the count too, and it needs the inbox rather than the password, so this action is for somebody who has lost access to that as well. It changes nothing else: the password, the sessions and the second factor are all left as they were.
A lost authenticator
Clear second factor appears only on an account that has one. It removes the flag, the stored secret and every unused recovery code, and ends the sessions that are open.
- /admin/users → user → Clear second factor.
- Confirm.
The user is notified in the portal that it happened. That notice is the point of the action being reviewable: somebody who did not ask for their factor to be cleared finds out that it was.
Do this only after you are satisfied the request came from the account holder, by some channel other than their portal account. The action removes the control that a stolen password would otherwise still run into, and a request to remove it is exactly what somebody holding a stolen password would send.
A user who still has a recovery code does not need you: entering one where the
six digits are asked for signs them in, and /profile then issues a fresh set.
Deactivating a user
Deactivation revokes all sessions and refresh tokens. The user cannot sign in. Their audit-log entries persist (rows are append-only).
- /admin/users → user → Deactivate.
- Confirm.
Reactivation is a single click on the same screen.
Deactivation is the only off-boarding action available in this release — there is no separate user-delete operation. To handle a GDPR erasure request, deactivate the user and contact engineering for a manual purge; a first-class soft-delete with typed-email confirmation is on the roadmap.
Creating a team
super_admin only.
- /admin/teams → New team.
- Name, description, optional default visibility for new projects (
team_onlyororg_wide). - Submit.
The first member of the team is whoever you assign on the next screen.
Nested groups
A team can have other teams nested under it, to any depth: a group hierarchy, not just one extra level. "Team" and "group" name the same underlying object in this product; this page says team when talking about membership and roles, and group when talking about where something sits in that hierarchy (its parent, its children, the whole branch under it). A group with no parent sits at the root of the organization, exactly like every team did before nesting existed.
Both actions below are super_admin only, from a group's detail drawer at /admin/teams.
Moving a group
- /admin/teams → open the group → Move.
- New parent → pick a group, or Root of organization (no parent) to detach it from any parent.
- Move.
The whole subtree moves in one operation: every descendant of the moved group keeps its place under it, just with a new ancestor chain above the move point.
The New parent list only offers groups that already satisfy both guardrails below, so the form itself refuses to construct an invalid move:
- Same organization only. A group can only move under a group in its own organization.
- Not under its own subtree. A group cannot move under itself or under any of its own descendants: that would make it its own ancestor.
If either guardrail is somehow bypassed and the request reaches the API directly anyway, the server still refuses it: moving a group under itself or under its own descendant returns 409 (Group Cycle Detected, extension cycle_detected: true).
Moving a group under a group in a different organization returns 422 (Cross-Organization Move Not Allowed, extension cross_organization_move: true). A standard deployment has exactly one organization, so there is nowhere else for a target group to belong; this check is a structural safety net, not something a normal admin workflow can trigger.
Creating a subgroup
- /admin/teams → open the parent group → Add subgroup.
- Name, slug, optional description.
- Create subgroup.
The new group inherits its parent's organization automatically: there is no organization picker here, unlike creating a root team.
A slug already used by one of the parent's other direct children is refused with 409 (Group Slug Conflict) before anything is written.
The permission cascade
Whether a group's contents are visible beyond its own direct-membership list is controlled by GROUP_CASCADE_ENABLED, on by default; the setting itself, including how to turn it off, is documented in .env.example. What turning it on changes, day to day:
- Nearest membership wins. A user's effective role at a group is the role of their nearest direct membership, walking up from that group toward the root. A
developerwho also holdsteam_admintwo levels up still getsdeveloperat the group closer to them: a demotion at a child always overrides a promotion at a parent, never the other way round.
- Sibling branches stay isolated. A membership in one group says nothing about a sibling branch, however deep. Access only ever flows down a single ancestor line, never sideways.
- The accessible set widens to the subtree. Someone with a direct membership in a group can see and reach everything under it: the dashboard, search, inventory, and every scan/project/license-policy/component-approval list widens to the whole subtree, not just that one group.
- Existence-hiding still applies. A group outside a user's accessible set 404s, not 403s, exactly like the flat model always has. The one visible exception is the ancestor breadcrumb on a group's detail page: it names every ancestor up to the root, even one the viewer cannot otherwise open, purely so they know where they are in the tree; it does not let them open that ancestor or learn anything else about it.
super_adminis unaffected either way. It bypasses both the flat and the cascaded checks at the gate layer regardless of this setting.
Turning GROUP_CASCADE_ENABLED off returns every check to exactly the flat behavior teams had before nesting existed: a user's role and accessible set are their direct memberships only, regardless of any parent a group carries.
A project's effective license/gate policy already walks up its group's ancestors to the nearest one with a policy set (the same nearest-wins shape as the cascade above), but that resolution is unconditional and does not depend on GROUP_CASCADE_ENABLED. Turning the flag off changes who can see a group's contents; it does not change which policy applies to a project. See Policy design.
Where /groups fits
/admin/teams is the admin management surface: super_admin only, and the only place to move a group or create a subgroup. /groups is a separate, read-only surface any authenticated member reaches from the main navigation; it lists the groups their membership (direct or cascade-inherited) lets them see, with drill-down navigation and a flat, whole-tree name search.
A group's detail page on /groups shows its ancestor breadcrumb, its direct members and its cascade-inherited members in two separate sections, its subgroups, and its projects. There is no move or create action here: those stay /admin/teams-only.
A group that does not exist, or one the caller cannot reach, returns the same 404 either way.
A single active-team selector in the top bar decides which team a new project is created under. Clicking New project from a group's detail page on /groups can point that selector at a group you can see through the cascade but do not hold a direct membership in; the project-creation form cannot submit into a group you are not a direct member of, so it shows an explicit Active team needs attention control instead of silently guessing a different team for you. Use the team switcher to pick a team you belong to directly, then create the project. This is expected behavior, not a bug.
Renaming a team
super_admin and the team's team_admin can rename a team. The team's name, slug, and description are mutable via PATCH /v1/admin/teams/{team_id}.
Team archiving (a hidden state that disables new project creation while keeping existing projects readable) is on the roadmap. In this release a team can only be renamed or deleted by a super_admin.
Deleting a team is guarded: DELETE /v1/admin/teams/{team_id} is refused with 409 (RFC 7807 body carries team_has_projects: true and project_count) while the team still owns any non-archived project, and with 422 (team_has_active_scans: true) while any project has a queued/running scan. Archive every project first (Project Settings → Archive), then delete the team. Archived projects do not block the delete — the team delete CASCADE-removes them (and their scans/findings) once no live project remains.
Sessions
| Token | Lifetime | Storage |
|---|---|---|
| Access token (JWT) | 30 minutes | Memory (in-app), Authorization: Bearer …. |
| Refresh token | 7 days, with rotation + reuse detection. | HttpOnly + Secure cookie, SameSite=Lax. |
Reuse detection: if a refresh token is presented twice, the entire token family is invalidated and the user is forced to re-authenticate on every device. This catches refresh-token theft.
Verify it worked
After onboarding a user:
- The user can sign in at
/loginwith the password they set during registration.
-
/admin/users lists the user with
is_active = true.SELECT count(*) FROM usersWHERE email = 'dev@demo.trustedoss.dev' AND is_active;
- The audit log records the team-add as a
membershipsinsert.
-
The user appears in the team's member list with the assigned role.
SELECT count(*) FROM memberships mJOIN users u ON u.id = m.user_idWHERE u.email = 'dev@demo.trustedoss.dev'AND m.role = 'developer';
Troubleshooting
A new user cannot register
Self-registration is open by default. Check that the user is hitting the correct URL (/register), the email passes basic format validation, and the chosen password meets the policy (≥ 12 chars, not in the NIST-banned list). Failed registrations log a structured warning on the backend:
docker-compose -f docker-compose.yml logs --tail=200 backend | grep -i register
Cannot promote my own role
Self-elevation is blocked. Ask another super_admin to do it. If you are the only super-admin, sign in as another super-admin (you should always have at least two).
"User already exists" when adding to a team
The email is already a portal account (possibly already a member of a different team). Use Adding an existing user to a team — the same flow finds them by email and just attaches the membership.
Roadmap
The following capabilities are described elsewhere in early docs but are not shipped in this release. They are tracked for upcoming minor releases:
- Email-based invitation flow with one-time 24-hour activation links and a
pendinguser status. - Soft-delete user action with typed-email confirmation modal.
- Team archive state (hide-and-disable while preserving read access).
See also
- API keys — service-account credentials
- Audit log
- Approvals
- Team structure - when to split a team versus nest a subgroup