Skip to content
Like what we’re building? Star on GitHub

Access control — who may do what ​

This page maps who may do what: the principals a credential resolves to, the gates that admit or refuse them, and the standing in a team or org a gate reads.

Drawn from the code at commit 61a305262 — main at v3.266.1, after #2391, which carries the launch rung; how this page is kept true: README. Every count below was re-taken at commit 98787101a (branch fix/org-tree-errors-1668), with the recipe below, the rung included.

Who can call ​

An "API key" is not a principal kind: api_keys rows are BYOK provider keys, a credential iterion uses, never a credential a caller presents.

KindMinted byCarriesPasses team gates?
session user — a JWT bearer, the cookie iterion_auth, or ?t=<access-jwt> on /api/ws and /api/ws/* alone. authCookieWriteName prefixes the cookie __Host- where the deployment can carry it, and sessionCookie under the prefix reads the prefixed name and refuses the bare one, and without it the prefixed name is ignored entirelyJWTSigner.IssueAccess; verified by Verify inside requireAuth (pkg/auth/jwt.go)UserID, Email, OrgID, OrgRole, TeamID, Role, IsSuperAdmin, JTIyes
PAT — iap_ bearerpat.MintToken (pkg/pat/pat.go), resolved per request by identityFromPATthe owner's identity, with the token's team as its request team — a super-admin's unpinned token takes the owner's default team, or none; Role re-read from GetMembership at every use; OrgRole left empty; its own expiry as ExpiresAt. It mints no other credential of its account: POST /api/auth/me/team/{team_id}, …/org/{org_id}, POST /api/me/tokens, the SSO link and the admin reset of its own account's password answer it 403 (auth.Service.CheckDerivation); it mints a WS ticket only, and every route the chokepoint gates reads its row again (CredentialID), so a WS ticket it minted dies with ityes
WS ticket — ?ticket= on /api/ws, every path under /api/ws/ — POST /api/ws/ticket included — and the CDP dialPOST /api/ws/ticket behind requireAuth, redeemed single-use by wsTicketIdentity (pkg/server/ws_ticket.go)the minter's identity verbatim — its credential kind, expiry and session included; one use, within one minute and never past the minting credential's expiry; minted only from a live access token or a PAT, and checked again as that credential at its redemption (CheckDerivation) — a PAT's row included; it mints nothing — POST /api/ws/ticket answers a ticket 403yes
dev-mode super-adminthe requireAuth short-circuit under cfg.DisableAuthUserID dev, IsSuperAdmin true, no team on the identity (the store context is stamped tenant dev), no roleyes
inbound webhook — KindWebhookwebhookAuth, after the URL-id lookup: a token compare in token mode only, the enabled check, then the per-webhook rate limit, the tenant's and the webhook's monthly call quotas and the team's CanLaunch, then the stamp — for SignModeHMAC the provider handler checks VerifyHMACSignature only after all of that (pkg/server/middleware_webhook.go)webhook:<cfgID>, the config's tenant, RoleMember, Kind webhookno
config-share editor — KindShare, iws_ bearerconfigShareAuth: a per-IP rate limit, the share lookup, VerifyToken and Active, then a per-share rate limit (pkg/server/middleware_configshare.go)share:<shareID>, the share's tenant, and no role at all; the pinned repo, ref and config path plus AllowedPaths / VisiblePaths and ReadOnly are the real authorityno
run-re-anchored identity — ViaidentityInTeam, called only from scopeRunByIDthe RUN's team, a role there — admin via super-admin, the caller's via membership, viewer via org-admin-lineage — and Via naming which standing carried ityes, in that team
per-run board/runs MCP token — X-Iterion-RunnewBoardMCPToken (pkg/server/mcp_board_handler.go)no identity: a boardMCPGrant of capabilities, tenant, source issue and expiryno
per-run forge publish grant — X-Iterion-RuninjectForgePublishVars at launch, into forgePublishTokens (pkg/server/forge_publish.go)no identity: a ForgePublishGrant of team, connection, repo, bot and capabilitiesno
internal background actor, syntheticthree background paths (webhooks_debounce.go, forge_gate_autofix.go, forge_gate_relaunch.go)the actor string, the webhook config's tenant or the team of the gating run's ForgePublishGrant, RoleMember, Kind webhookno
machine actor — KindInternalmachineLaunchCtx (pkg/server/launch_gate.go)the metered tenant, the audited actor, Kind internal, and no role at allnever reaches one
anonymousnobody — authMiddleware returns before requireAuth for isPublicPath, an OPTIONS preflight, or a credential-less marketplace read (public view: no account ids, see marketplace.ForViewer) unless DisableAuthnothing; auth.FromContext answers ok=falseno

Roles ​

  • The team ladder — viewer (1) < member (2) < admin (3) < owner (4).
  • No gate in pkg/server requires AtLeast(RoleOwner).
  • config_editor is orthogonal, not a rung (ADR-078). It ranks 0, so AtLeast places it nowhere. canEditConfigShares has a config_editor term (and canEditBots, which delegates to it). It is not thereby a one-capability role: the role-blind surfaces admit it like any team principal — for example requireMemoryWriteAuth on non-global spaces, canMutateScopedRecord for its own records, the cost-cap override, and the run list and stats of its active team.
  • The org ladder — member (1) < admin (2) < owner (3). No gate asks for owner.
  • is_super_admin is a flag on the user, not a role value. It short-circuits every team/org can* gate and bypasses gateLaunch entirely; some gates are not bypassed — the origin gate, the ghost refusal and requirePlatformApiKey, for example.
  • The org mirror rule — OrgRoleForTeamRole: AtLeast(RoleAdmin) → OrgRoleAdmin, AtLeast(RoleViewer) → OrgRoleMember, anything off the ladder (config_editor, or an empty role) → "", i.e. no org membership at all — the runtime mirror writes none, skipping before its upsert; the iterion migrate orgs backfill does not skip it: its upsert of the empty role is refused, and the migration aborts mid-run.

The decision points ​

Call sites are counted over pkg/ and cmd/, excluding _test.go, as grep -rn '\.<symbol>(' pkg cmd --include='*.go' | grep -v _test.go | wc -l — the declaration is not a call site. Two rows do not take that form: ghostTeamMutatingRefusal is a package-level function, counted as ghostTeamMutatingRefusal( because the dotted form matches nothing, and the run WS mutating-envelope row names a surface whose gate is a map lookup inside runConn.dispatch, so it carries the count of its enforcement site. A third, handlePutOrgMember, is mounted as a function value, so the recipe answers 0.

PointFilePasses whenCall sites
requireAuthmiddleware.goDisableAuth, or a valid single-use ticket, or an iap_ PAT that resolves, or a JWT that verifies — then scopeRunByID and stampAuthedContext both succeed, and the ghost refusal does not fire. DisableAuth short-circuits before the first two, not before the ghost refusal192
stampAuthedContextmiddleware.gothe identity carries a TeamID, or the path is tenant-free (/api/auth/, /api/me/, /api/orgs/, /api/teams/, /api/admin/, a public marketplace GET)2
scopeRunByIDrun_scope.gothe request is outside the run-by-id class, or the run resolves team-blind, identityInTeam yields a standing and runScopeLadder accepts the method; either way stampAuthedContext runs next and can still refuse2
identityInTeamrun_scope.gonot synthetic, and, in this precedence: super-admin (re-anchored as admin); else the membership row if it is AtLeast(RoleViewer), and a row that ranks LOWER (config_editor, an empty role) refuses outright; the parent-org admin/owner arm (re-anchored as viewer) applies only when NO membership row exists — and beside it, on a team whose run-visibility axis (ADR-148) is opened to the org, a plain org member of that org reads as viewer too (org-shared)1
runScopeLadderrun_scope.goAtLeast(RoleViewer) for a GET or HEAD outside browser/cdp and shell; AtLeast(RoleMember) for every other method and for those two GETs1
effectiveMembershippkg/auth/service.goa membership row wins; without one, a super-admin steps into any team and an org admin/owner into any team of THEIR org, re-anchored as admin. It decides which team and which role the next JWT carries, behind POST /api/auth/me/team/{team_id} — requireAuth, then CheckDerivation (a personal access token: 403; a disabled or pending account, a revoked session: 401), and no role gate1
canViewTeamauth_authz.gonot synthetic, and: super-admin, or the claim's team with AtLeast(RoleViewer), or a stored membership AtLeast(RoleViewer), or orgAdminOfTeam. It is the fail-closed form of canViewTeamOrFail, the same predicate returning the read that stopped a "no" — taken by handleListTeamMembers alone25
canManageTeamauth_authz.gonot synthetic, and: super-admin, or a stored membership AtLeast(RoleAdmin) — no claim shortcut — or orgAdminOfTeam52
canEditConfigSharesauth_authz.gonot synthetic, and: super-admin, or Role == RoleConfigEditor on the claim's team or in the store, or canManageTeam3
canEditBotsauth_authz.goexactly canEditConfigShares3
canViewOrgauth_authz.gonot synthetic, and: super-admin, or the claim's org with a Valid() org role, or a stored org membership with a Valid() role9
canManageOrgauth_authz.gonot synthetic, and: super-admin, or a stored org membership AtLeast(OrgRoleAdmin) — the store, never the claim. The fail-closed form of canManageOrgOrFail, the same predicate returning the read that stopped a "no" — taken directly by handleCreateTeam alone27
orgAdminOfTeamauth_authz.gothe team resolves, its OrgID is non-empty, and canManageOrg passes on that org. The fail-closed form of orgAdminOfTeamOrFail2
requireOrgStandingToWriteOrgRoleauth_authz.goan org-membership role write, after the route's own gate: the written role mirrors to no org role, or the team has no parent org, or super-admin; else the writer's stored membership in that org AtLeast the role written1
requireSuperAdminmiddleware.gorequireAuth passes and isSuperAdmin holds61
isSuperAdminmiddleware.gocfg.DisableAuth, or the ctx identity's IsSuperAdmin3
canMutateScopedRecordgeneric_secrets_routes.gosuper-admin; or a user-scoped record whose ScopeUserID is the caller's; or an org-tier scope and canManageOrg; else canManageTeam2, from canMutateApiKey (byok_routes.go, 2) and canMutateGenericSecret (2)
requireMemoryWriteAuthmemory_routes.gothe space's visibility is not global, or there is no identity store, or the caller is super-admin3
requirePlatformApiKeyadmin_llm_routes.gothe addressed key exists and its ScopeTeamID is the platform tenant2
canRouteToConverseBotwebhooks_gitlab.gothe webhook config's own allow-list admits the bot id and botExistsForTeam resolves it in the config's tenant — a bot allow-list, with no role term2
run WS mutating-envelope gateruns_ws.gothe envelope is not in runWSMutatingTypes, or the connection's role is empty (no auth context at all), or AtLeast(RoleMember)1
marketplace moderation queuemarketplace_cloud.goid.HasRole(RoleAdmin) — no store re-read, no team path and no org-admin arm; it gates the GET …/moderation queue alone1
marketplaceModerateGatemarketplace_cloud.gothe approve and reject actions: an org-scoped entry takes canManageTeam on the submitting team, a public or instance entry takes a super-admin1
gateLaunchlaunch_gate.gono identity store, or super-admin; else a non-empty TeamID, then the rung — launchRungCleared, i.e. launchExemptPrincipal or AtLeast(RoleMember), else member_role_required — then a team that exists and CanLaunch (an unreadable team admits it ungated), its org CanLaunch (an org that cannot be read is skipped), then the per-repository quota, concurrency, launch rate and monthly caps10, plus the trigger spine's injected l.gate: eleven launch surfaces
mutatingRungDenialboard_cloud_routes.gono identity store, or a method that changes nothing, or super-admin, or launchRungCleared — the same rung gateLaunch reads. Same member_role_required 403, rendered through writeRungDenial, and no teamless arm of its own: a team-less credential is refused by stampAuthedContext before any mount4: mutatingRungAuth on the request path — plus boardWriteDenial, triggerWriteDenial and memoryWriteDenial, the per-family instances the class witnesses call and no production path does
originGateAllows / requireSafeOriginmiddleware.go, server_files.gooriginGateAllows: the request is not a state-changing /api/ call, or its Origin is same-origin, loopback on the server's own port, the configured public URL, a desktop origin, an ITERION_ALLOWED_ORIGINS entry, or absent; requireSafeOrigin admits the same Origins; ITERION_REQUIRE_ORIGIN=0 disables both2 and 85
handlePutOrgMemberteam_lifecycle_routes.gosuper-admin only0 by the recipe
ghostTeamMutatingRefusalmiddleware.gorefuses — it never admits: a 404 for a state-changing call on /api/teams/{id} or a route below it whose {id} canonicalizeTeamPathValue could not resolve, and only where canManageTeam would have admitted the caller3 by the recipe — two calls plus the func line

The table holds shared helpers and the handler gates the matrix has a row for; any handler may add a conjunct of its own.

The gate decision ​

The matrix ​

Decision pointteam viewermemberadmin / ownerconfig_editororg memberorg admin / ownersuper-adminsynthetic webhooksynthetic sharemachine-internal
canViewTeam✅✅✅❌❌✅ their org's teams✅❌❌n/r
canManageTeam❌❌✅❌❌✅ their org's teams✅❌❌n/r
canEditConfigShares❌❌✅✅❌✅ their org's teams✅❌❌n/r
canEditBots❌❌✅✅❌✅ their org's teams✅❌❌n/r
orgAdminOfTeam❌❌❌❌❌their org's teams onlya team with an org only❌❌n/r
canViewOrg❌❌❌❌✅✅✅❌❌n/r
canManageOrg❌❌❌❌❌✅✅❌❌n/r
requireSuperAdmin / isSuperAdmin❌❌❌❌❌❌✅❌❌n/r
handlePutOrgMember — place an existing account in an org❌❌❌❌❌❌✅❌❌n/r
canMutateApiKey / canMutateGenericSecret → canMutateScopedRecordownownown · team rows ✅ · org-tier → org adminownownown · their org's team rows ✅ · their org's org-tier ✅✅❌❌n/r
requireMemoryWriteAuthtenant ✅ · global ❌tenant ✅ · global ❌tenant ✅ · global ❌tenant ✅ · global ❌needs a teamtenant ✅ · global ❌ — step-in (admin claim)✅n/rn/rn/r
canRouteToConverseBotn/rn/rn/rn/rn/rn/rn/rendpoint opt-inn/rn/r
marketplace moderation queue (HasRole)❌❌✅❌❌✅ via the step-in admin claim✅❌❌n/r
marketplaceModerateGate — approve / reject❌❌org entry only❌❌org entry only✅❌❌n/r
the run-addressed surface — scopeRunByID + runScopeLadder, two exceptions: browser/cdp and shellladder (GET viewer · action member)✅✅❌org-shared → viewer on a team whose run-visibility axis is opened (ADR-148)org-admin-lineage → viewer, and only with NO membership row✅❌❌n/r
run WS mutating envelopes❌✅✅❌❌ identityInTeam's 404 first❌✅n/rn/rn/r
launch — the member rung of gateLaunch❌ member_role_required (on resume the ladder's own 403 first; on the WS answer the socket's member rung first)✅✅❌ member_role_required (on resume and on the WS upgrade alike, identityInTeam's 404 first)needs a team (on resume and on the WS upgrade, identityInTeam's 404 first)✅ step-in (admin claim) — except resume and the WS answer✅✅ launchExemptPrincipaln/r✅ launchExemptPrincipal
board write — the same rung at mutatingRungDenial, through the boardWriteAuth mountread ✅ · write ❌ member_role_required✅✅read ✅ · write ❌ member_role_requiredneeds a team✅ step-in (admin claim)✅n/rn/rn/r
trigger write — the same rung at mutatingRungDenial, through the triggerWriteAuth mountread ✅ · write ❌ member_role_required✅✅read ✅ · write ❌ member_role_requiredneeds a team✅ step-in (admin claim)✅n/rn/rn/r
memory write — the same rung at mutatingRungDenial, through the memoryWriteAuth mount (a global space then takes requireMemoryWriteAuth on top)read ✅ · write ❌ member_role_required✅✅read ✅ · write ❌ member_role_requiredneeds a team✅ step-in (admin claim)✅n/rn/rn/r

Legend: ✅ passes · ❌ the gate refuses · own only a record whose ScopeUserID is the caller's · org-tier → org admin an org-tier scope defers to canManageOrg · org entry only the gate admits an org-scoped entry and reserves public and instance entries for a super-admin · step-in (admin claim) the principal holds no membership row but effectiveMembership — or pickActiveTeamInOrg on an org switch — minted an admin claim on the team · ladder (GET viewer · action member) the ADR-103 ladder, whose only two exceptions are browser/cdp and shell — GETs that hand live control, so they take a member · their org's teams the org-admin arm of a team gate is orgAdminOfTeam, which reads the team and defers to canManageOrg on its OrgID: a team of another org refuses · a team with an org only orgAdminOfTeam reads the team and defers on its OrgID: a team that does not resolve, or whose OrgID is empty, refuses — every caller admits a super-admin beside it · tenant ✅ · global ❌ every visibility but global passes · read ✅ · write ❌ a safe method is served, a state-changing one takes the rung · endpoint opt-in the surface authenticates the principal itself and reads no role · n/r not judged: that principal cannot address the surface — a self-authenticating credential only authenticates its own paths, and a machine-internal actor is a daemon of this process, not a request; a ❌ in the two synthetic columns says the gate would refuse the principal even if it got there.

Reading notes:

  • A column is a standing, not a person, and holding two standings is not always holding the better one: a stored membership row wins over the org-admin arm whatever its rank — in identityInTeam on the run-addressed surface, in effectiveMembership, and on an org switch, where pickActiveTeamInOrg lands on any row the user holds in the org before it steps in.
  • The two org rows read OrgRole only; a team role never admits them. A team admin usually also holds admin in the org because OrgRoleForTeamRole mirrored it there, not because the gate looked at the team.
  • needs a team: an org-only principal carries no TeamID. An org admin/owner of the team's org leaves it only through a switch: effectiveMembership — or pickActiveTeamInOrg on an org switch — steps them into a team of their org where they hold no membership row, with a synthesized admin role, and gateLaunch reads id.Role off the claim with no store re-read — so a role claim does not imply a membership row.
  • The launch row is the only one whose gate is also an admission gate: past the rung, gateLaunch is team status, org status, the per-repository quota, concurrency, launch rate and monthly caps, and that part fails open on a degraded store read.
  • The config-editor schedule route is a door the rung does not hold: PATCH …/config-editor/shares/{sid}/schedule takes canEditConfigShares, so a config_editor can re-time a share's enabled schedule, and the tick launches it under the scheduler's machine identity.
  • The run WS envelope gate: an org-admin-lineage connection arrives as a viewer and is refused.
  • POST /api/v1/limits/cost/override reads no role at all: any authenticated principal with a team can lift the day's cost cap — a local-mode cap, which cloud does not keep.

What is not RBAC ​

Other permission systems read no team role either — for example:

  • permissions.md — the node tool-permission modes (off / ask / deny): what the MODEL may call inside a run.
  • forge-permissions.md — the forge connection's own token scopes: a bot's repo rights are the connection identity's rights, team-scoped and identical for every member, whatever their iterion role.
  • sandbox.md — per-run network egress (open, allowlist or denylist): where a run may talk, not who may start it.
  • adr/018-bot-secret-binding-egress-enforcement.md — a bot secret binding's AllowedHosts, which can only narrow the workflow's hosts, never broaden them.
  • memory-and-knowledge.md — memory space visibility, a sharing axis rather than a role; only global crosses tenants; on the REST routes only requireMemoryWriteAuth admits a write there, while a claw node whose memory: block sets enabled: true and visibility: "global" (with its scope:; write defaults to true) writes it from a run with no gate.

Under all of them sits the store's tenancy stamping (WithTenant, WithIdentity, and documented escape hatches such as WithoutTenantFilter and TeamBlind, pkg/store/tenant.go): not a gate itself, and a boundary only where a store filters on it — see cloud-architecture.md.

Surface ​

460 routes. Distinct method-plus-pattern literals registered through the recording mux, counted from the repository root:

sh
for f in pkg/server/*.go; do case "$f" in *_test.go) continue;; esac
  grep -hoE 's\.mux\.Handle(Func)?\("[A-Z]+ [^"]+"' "$f"
done | sed -E 's/^s\.mux\.Handle(Func)?\("//; s/"$//' | sort -u | wc -l

Three things the count does not say:

  • Sub-trees mounted on the bare ServeMux behind the recorder are not in it: pkg/dispatcher/native/http.go (/api/v1/native) and pkg/dispatcher/manager.go (/api/v1/dispatcher) register their own, as do the board and runs MCP transports and the three X-Iterion-Run forge routes (server_routes.go, route_recorder.go).
  • A route can be absent rather than refused with a 403, depending on the deployment.
  • 74 of the 460 are the run-addressed class: a wildcard at the id position under /api/runs or /api/ws/runs.