Skip to content

Permissions & Policy

Scion implements a robust, principal-based access control system to manage resources across distributed projects and teams. The system is built on the Permissions Foundation architecture, providing deterministic authorization evaluation, declarative route guards, and comprehensive auditing.

For a detailed technical specification of the permissions model, role definitions, access constraints, and agent identity claims, see the Permissions & Access Constraints Reference.

Scion uses a UnifiedAuthMiddleware to enforce declarative route guards across the Hub. Every request undergoes deterministic authorization evaluation via a Decide path before reaching the handler, ensuring no resource can be accessed without explicit permission. Engine internals, settings handlers, User Access Token (UAT) endpoints, user management, integrations, and operations have all been converted to explicit permission-based checks, deprecating the legacy requireAdmin fallback.

Access is granted through explicit role assignments:

  • RoleDefinition: A named collection of permissions (e.g., developer, viewer, admin).
  • RoleBinding: A grant of a RoleDefinition to a principal (user, group, or agent) within a specific scope (Hub or Project).
  • Project Membership: Users gain access to project resources by being bound to a role within that project.
  • Multi-Role Membership: A principal (user, group, or agent) can hold one built-in project role (project-owner, project-admin, project-member, or project-viewer) plus any number of custom project-scoped roles. The project Members editor in the web UI shows one row per principal with its full role set, and saves the whole set atomically (PUT /api/v1/projects/{id}/members/principals/{type}/{id}, see the API reference). The last usable owner (an in-effect owner binding of an existing, active user) cannot be removed or demoted, nor can the last owner binding of any kind, and built-in role changes follow the owner/admin governance rules. Custom roles are governed as described under Role & Binding Management in Managing Access and Infrastructure below; the editor offers only the roles the Hub reports as grantable to you (GET /api/v1/projects/{id}/members/assignable-roles).
  • Project Ownership Comes Only From Role Bindings: A project’s ownerId field is informational and grants no access. Authority over a project comes only from project-scoped role bindings, so a user whose only claim was ownerId (for example a creator who was later removed from the project) is denied. Reading a project does not re-grant project-owner to its creator. Transferring ownership (POST /api/v1/projects/{id}/transfer-ownership) grants the new owner project-owner, downgrades the previous owner to member, and moves ownerId. At startup the Hub logs a warning, and grants nothing, for each legacy project whose ownerId user has no project-owner binding; an admin must add an owner binding for those projects.
  • Leaving a Project: While the hub.project_self_leave experiment is on, a user who holds a direct project role can leave the project. In the web UI, Leave project is in the project page’s header menu (every member can reach it), in the Danger Zone of project settings above Delete, and on the user’s own row in the Members editor, where it replaces Remove. The action shows only when the project’s capabilities report canLeave. When a leave is blocked it is shown disabled with the reason: the last usable direct owner must transfer ownership or delete the project first, and a member only through a group must leave the group. The dialog first runs a dry run and lists what leaving changes: the roles removed, the user’s agents in the project (up to 50, with a count of the rest) that will be stopped and put on hold, the access tokens limited to the project that will be revoked, and that shared secrets and artifact steward rights stop applying. When the user keeps access through a group or a hub role, the dialog says so and lists no agents. Delete these agents before leaving deletes the listed agents one at a time and waits until the Hub confirms each delete. If a delete fails or cannot be confirmed, the dialog names the agent, offers Retry or Cancel, and does not leave. The option is disabled when the list is too long to show in full. A last-owner refusal links to Transfer ownership. After leaving with no remaining access, the user goes to the projects list and the project leaves it; a project page that later answers 404 shows that the user no longer has access. The API is POST /api/v1/projects/{id}/leave, which accepts an interactive session only.
  • Project Members Groups Are System-Managed: Each project’s members group is created by the Hub without an owner, and a startup backfill clears the owner on existing members groups, so a former project creator keeps no group-level authority over it. Updating, deleting, or changing the membership of a members group through the group API (/api/v1/groups) is restricted to hub admins, and no one — not even a hub admin — can set an owner on it. Manage project membership through the project members endpoints (/api/v1/projects/{id}/members/...) or the project Members editor instead.
  • CanDelegate Admission Gate: Prevents lateral privilege escalation by ensuring a principal can only grant roles or permissions they themselves possess.
  • Delegation Ceiling: An agent acts with authority delegated by whoever created it, and that authority can never exceed its delegators’. For lifecycle actions and agent creation, every link in the agent’s delegation chain must name a live delegator that holds the permission on the target resource: an active user, or an agent that exists and is not deleted (a stopped agent still counts). A deleted or deactivated delegator supplies no authority. The chain walk is limited to 10 links, and a cycle, a chain that is too deep, or a failed lookup denies the request. Delegation edges are project-scoped, so a resource in another project is denied.
  • Frozen Delegation Ceilings and Provenance: When an agent is created, the Hub records where its authority came from (the creating credential) and freezes that credential’s permission ceiling on the delegation edge. The child’s role is capped to what the creating credential covers — for example, an agent created with a narrowly scoped user access token gets the highest role the token’s scopes cover, and an explicit --role above that is rejected with 403. The delegation walk then applies each hop’s frozen ceiling, and agent token mint and refresh only issue scopes inside the chain’s ceiling. Edges created before provenance was recorded still work for ordinary actions, but are denied for permissions that issue, use or deliver sensitive material (GCP service account use and assignment, secret read/use/delivery, env-var and skill delivery, and agent identity tokens). Such edges are also denied artifact.read and artifact.create (and every permission still marked reserved, which today includes artifact.update and artifact.delete) of the experimental artifact service, and agent tokens issued along such a chain leave out the project:artifact:read and project:artifact:write scopes. An agent with no delegation edge is denied the artifact permissions too, and once every agent has an edge recorded, an agent without one is limited to non-sensitive reads and cannot get a new agent token. Recreate such agents to restore those capabilities (or have a user reincarnate them, see below).
  • Delegation Edges Follow the Agent: Creating an agent records the agent, its delegation edge, provenance and audit record together; if dispatch to the Runtime Broker fails, they are all rolled back. Deleting an agent deactivates its edges, and restoring it reactivates them. A restore fails with 409 if a delegator is no longer active or a reactivated edge conflicts with an active one (and 503 if the Hub cannot check the delegators), leaving the state unchanged. To reincarnate another principal’s agent you must be able to delegate the agent’s role in the target project. The agent keeps its existing delegator and frozen ceiling unless you change its role with --role; then you become its recorded delegator, and a requester that descends from the agent is refused with 403, because the new edge would close a loop in the delegation chain. If you are an agent, the agent’s authority then depends on your delegation chain, so deleting you later takes it away; prefer a user for role changes on long-lived agents. One exception repairs agents left without recorded provenance, for example after a hub upgrade: when a user reincarnates an agent without changing its role and the agent’s own delegation edge is missing or has no recorded provenance, or an edge further up its chain has no recorded provenance and every edge between it and the agent is accepted on this hub, the user becomes its recorded delegator, with recorded provenance, which clears the ceiling_unrecorded denial. A reincarnation by the agent itself or by another agent keeps the edge, as does a user’s reincarnation of an agent whose chain is fully recorded, or whose first problem walking up from the agent is an edge this hub does not accept (for example one recorded with local development credentials on a hub without dev auth); recreate such an agent. An agent whose chain has a missing ancestor edge or a loop is suspended, and a reincarnation of it is refused with 409. An agent whose delegator was changed by an earlier reincarnation by another agent (for example, one that now gets 403 when creating agents) is not repaired by this rule, because its chain is recorded; recreate it. Two role changes by a signed-in user (for example to baseline and back) also re-point its edge to that user, but recreating is recommended.
  • Credential Revocation: Agent credentials and User Access Tokens can be instantly revoked, terminating access system-wide.
  • Agent-Bound Delegation Grants (hub.agent_delegation experiment): With the hub.agent_delegation experiment on, a local interactive user session can issue a bounded delegation grant (POST /api/v1/agents/:id/delegations, permission agent.delegation.create) to an agent the user controls (owner or in ancestry), binding the grant to the agent’s current project and generation, a project or hub boundary, and a frozen permission ceiling narrowed by agent_delegation_policy (up to 10 active grants per agent and 50 per issuer; default lifetime 7 days, maximum 30 days; credential TTL up to maxCredentialTtlSeconds, default 900s, maximum 3600s; no subdelegation).
    • In-container proxy socket: Inside the agent container, sciontool init listens on a Unix domain socket at ~/.scion/delegation.sock (mode 0600, owned by the agent user; on Linux SO_PEERCRED accepts only the agent UID or sciontool’s UID). Processes in the container send /api/v1/... HTTP requests over that socket with X-Scion-Delegation-Grant: <grant-id> (up to 4 MiB body; redirects are not followed). sciontool exchanges the grant (POST /api/v1/agents/:id/delegations/:grantId/exchange) using the agent’s own token, holds the scion_adt_... delegated credential in memory only (at most 2 active credentials per grant before the oldest is superseded), and refreshes it before expiry and whenever the agent’s own token refreshes or resets.
    • Admitted routes and message attribution: An agent delegated credential is admitted only on GET /api/v1/agents (agent.list, filtering to agents where both the grant and the issuer hold agent.read; view, phase, projectId, limit, cursor, runtimeBrokerId, and label are supported, while sort or ids answers 400 delegated_list_sort_unsupported and scope=mine, scope=shared, and mine=true list nothing), GET /api/v1/projects/:id/agents (agent.list and agent.read on that project when named by project ID; a slug in the path answers 403 credential_not_admitted, and sort or ids answers 400 delegated_list_sort_unsupported), GET /api/v1/skills (skill.list and skill.read on project-scoped skills within the grant boundary and the issuer’s live access; scope=global, scope=core and scope=user list nothing), GET /api/v1/templates (template.list and template.read on project-scoped templates within the grant boundary and the issuer’s live access; scope=global, scope=core and scope=user list nothing; on every delegated list, each returned item carries only the read capability and the response has no list-level capabilities — see Delegated lists), GET /api/v1/agents/:id (agent.read), GET /api/v1/projects/:id (project.read), GET /api/v1/agents/:id/pty (agent.attach on the WebSocket handshake and preflight; other agent.attach routes such as exec, env, reset-auth, and keys are not admitted), and POST /api/v1/agents/:id/message / POST /api/v1/projects/:projectId/agents/:agentIdOrSlug/message (agent.message). A delegated message send is limited to a direct text message (instruction, chat, or reply) to the route’s own agent (no group recipient, thread, conversation, mention, notification subscription, or interrupt) and is charged to the actor agent’s send budget. Both the grant (agent.message on the target) and the actor agent’s own messaging evaluation (message modes, cross-project policy, and standing) must allow; the issuer’s ownership or ancestry never pierces a message mode. The stored message records the actor agent as sender (agent:<slug>) and senderId, and stamps authorizingUserId (the grant issuer) and delegationGrantId.
    • Inspection and lifecycle revocation: Management routes (GET /api/v1/agents/:id/delegations, GET and DELETE /api/v1/agents/:id/delegations/:grantId, DELETE /api/v1/agents/:id/delegations/:grantId/credentials/:credentialId, POST /api/v1/agents/:id/delegations/revoke-all (agent.delegation.revoke_all, held by super-admin and hub-admin), GET /api/v1/users/me/delegations, and POST /api/v1/users/me/delegations/revoke-all) stay available even when hub.agent_delegation is off so operators, issuers, agent owners, and the bound agent itself can inspect and revoke or relinquish grants. Soft-deleting, hard-deleting, restoring, or claiming an agent for reincarnation revokes all unrevoked grants bound to it (and their exchanged credentials) in the same transaction (agent_deleted, agent_restored, agent_reincarnated); deleting a project revokes grants of all project agents in the cascade transaction (project_deleted); suspending the agent (agent_suspended) or suspending or deleting the issuing user (issuer_suspended, issuer_deleted) revokes grants after commit while exchange and use checks deny immediately.
  • Decision & Mutation Audit: All authorization decisions and role mutations are captured in a structured audit log.
  • Explain API: Administrators can use the Explain API to query why a specific permission was granted or denied for a principal on a given resource.

A Principal is an identity that can be granted permissions.

  • Users: Identified by their email address.
  • Groups: Collections of users or other groups, allowing for hierarchical team structures.

Permissions are granted on specific resource types:

  • hub: The global Scion Hub instance.
  • project: A project-level workspace.
  • agent: An individual agent instance.
  • template: An agent configuration blueprint.
  • scheduled_event: A time-based recurring schedule or scheduled event.

Scion uses a standardized set of actions:

  • CRUD: create, read, update, delete, list.
  • Administrative: manage.
  • Resource-Specific: lifecycle (start, stop, suspend, restart, restore), attach (terminal, exec, env, reset-auth, the POST /:id/keys literal-terminal-input operation), port_access, message.

Scion enforces strict role-binding-based authorization for all agent operations:

  • Agent Creation: Requires active membership in the target project.
  • Agent Interaction: Interacting with an agent (e.g., via PTY/terminal or structured messaging) is restricted to the agent’s owner (the creator), users in the agent’s ancestry chain, or system administrators. The default project-member role does not grant the agent:message permission — messaging authorization is aligned with the terminal attach permission gate.
  • Lifecycle vs. Attach: Lifecycle operations (start, stop, suspend, restart, restore, reincarnate) are gated by agent.lifecycle, separately from agent.attach (terminal, exec, env, reset-auth) and agent.port_access. Because an agent runs with its creator’s user-scoped secrets, the built-in project-owner and project-admin roles grant agent.lifecycle, messaging and agent.port_access but not agent.attach. Owners and admins can start, stop, and message other members’ agents and open their forwarded ports, but cannot open a terminal on them. project-member does not carry agent.port_access; grant it to members through a custom project role. Owners and admins keep full access to their own agents and descendants through the resource-owner and ancestry grants.
  • Keys (POST /:id/keys): Sending literal terminal input through this dedicated Hub operation is authorized exactly like agent.attach, never through agent.message or a message-mode setting — a closed or none message mode does not block it, and message authority alone does not grant it. A human session or User Access Token needs to own the target agent or hold live agent.attach (ActionAttach) on it — no built-in project role grants agent.attach; an agent credential additionally needs ScopeAgentLifecycle, the same project as the target, and a live attach relationship on the target evaluated through the shared relationship pipeline — same-project membership alone is not sufficient for an agent caller. An authenticated agent crossing its own project boundary is refused (422) on the project-scoped route unconditionally — that comparison runs before any scope or target check — and on the top-level route provided it already holds the lifecycle scope above (an agent lacking that scope is refused with 403 keys_denied first, regardless of project). The disclosure also differs by route: on the project-scoped route this is decided before any target lookup, so it discloses nothing about whether the target exists in the foreign project; on the top-level route the target is resolved first (as for every other action on that route), so a foreign existing agent and a nonexistent one get different outcomes — the same disclosure trade-off that route’s other lifecycle actions already make today, not a new trade-off introduced by keys. A federated (cross-Hub) agent identity is never one of the two caller kinds above — it is always denied, the same as any other unrecognized principal kind. The scion keys CLI command calls this operation. Raw delivery through POST /:id/message has been removed: a request carrying raw is rejected with 422 raw_input_removed — see CLI Reference and API Reference.
  • Agent Deletion: Only the agent’s owner, a system administrator, or authorized agent callers can delete an agent. For an agent caller to perform a deletion, it must have project:agent:lifecycle (associated with the full role) and must target an agent within its own project (which closes a cross-project agent deletion vulnerability).

Membership-Based Project Access (Visibility Eradication)

Section titled “Membership-Based Project Access (Visibility Eradication)”

The legacy, non-functional project Visibility field (e.g., private, team, or public) has been completely eradicated. Instead, access control is governed entirely by membership-based policies. The same applies to agents, templates, harness configs, and skills: their visibility field has been removed from the API (including the agent SSE payload), and access depends only on scope and grants. Skill create and update requests that still include visibility are rejected with 400 validation_error. User- and project-scoped templates, harness configs, and skills are readable only by their owner, project members, and Hub admins; Hub-wide member and viewer grants cover only hub- and global-scoped records (see Security).

  • Project Scope Governance: Access to a project and its associated resources is restricted to its members. A principal is a member when it holds any active project-scoped role binding, built-in (project-owner, project-admin, project-member, project-viewer) or custom, directly or through a group. A project-visitor binding is not membership. Expired, not-yet-active and revoked bindings do not count, and a group never confers project-owner. A principal whose only project membership is project-viewer can read the project, its agents, and its chat threads, but is not admitted for actions that require membership beyond project-viewer: cross-project agent messaging under the members inbound policy (cross_project_origin_not_member), runtime secret and environment-variable delivery to agents, and project-scoped secret delivery to agents of test-admin identities.
  • System-Managed Members Group: Each project has a members group (project:<slug>:members) that Scion manages. It cannot be the principal of a role binding on any scope, and it cannot be added as a child of another group: such requests fail with 400 principal_ineligible (details.reason: project_members_group). Grant roles to the members individually or to a dedicated group instead. Deleting an existing binding or child-group edge is still allowed.
  • Fail-Closed Retrieval (404 Gate): Project read access is verified via a CheckAccess gate on retrieval. If a caller is not authorized to read the project, the API responds with a standard 404 Not Found (rather than a 403 Forbidden) to prevent callers from probing the existence of private projects.

Scheduler Authorization & Owner-Based Access Control

Section titled “Scheduler Authorization & Owner-Based Access Control”

Scheduled events and recurring schedules are strictly protected using an Owner-Based Access Control model, combined with dedicated permissions and dynamic RoleBindings:

  • Owner-Based Protection: Only the creator (the owner) of a schedule/event, or a system-wide administrator, has the authority to view, update, delete, or otherwise manage a scheduled event or recurring schedule. This is enforced via creator/owner ID validation at the API handlers layer.
  • Project Member Permissions: The built-in project-member role carries scheduled_event.create, scheduled_event.list and scheduled_event.read, so members can schedule events within their project; project-admin also carries scheduled_event.update.
  • Scheduler Permissions: A set of 7 dedicated permissions are enforced across scheduler endpoints:
    • scheduled_event.read: Permission to read a scheduled event or recurring schedule.
    • scheduled_event.list: Permission to list scheduled events and recurring schedules.
    • scheduled_event.create: Permission to create a scheduled event or recurring schedule.
    • scheduled_event.update: Permission to update a recurring schedule (including pausing/resuming).
    • scheduled_event.delete: Permission to cancel a scheduled event or delete a schedule.
    • hub.scheduler.read: Permission to read hub-wide scheduler configurations.
    • hub.scheduler.update: Permission to update hub-wide scheduler configurations.

Positive Authority & Monotonic Restrictions

Section titled “Positive Authority & Monotonic Restrictions”

Scion operates on a single positive-authority model using RoleBindings to grant permissions, supplemented by AccessConstraints to enforce maximum boundaries.

All permissions in Scion are additive and must be explicitly granted via a RoleBinding.

  • RoleDefinition: A named set of allowed permissions (e.g., project:viewer, project:developer, hub-admin).
  • RoleBinding: Connects a principal (User, Agent, or Group) to a RoleDefinition.
  • Scope: RoleBindings exist at either system scope (system-wide permissions across the entire Hub) or project scope (permissions restricted to a single project space).

Monotonic Restrictions (AccessConstraints)

Section titled “Monotonic Restrictions (AccessConstraints)”

An AccessConstraint is a maximum-permissions boundary that can only reduce (never widen) a principal’s granted authority. It acts as an absolute ceiling.

  • Ceiling Enforcement: If a RoleBinding grants a principal 10 permissions, but an AccessConstraint limits that principal to a maximum of 3 specific permissions, the principal will only have those 3 permissions.
  • Targeting: AccessConstraints can target specific principals, entire group closures (a group and all its subgroups), or all principals (all_principals).
  • Offline Recovery: Under disabled: true, an AccessConstraint is deactivated. This is used in offline recovery to restore administrator access in the event of a lockout.

On any authorization request (evaluated via the Hub’s Decide endpoint):

  1. Load Bindings: The engine loads all active RoleBindings for the principal (including group memberships and synthetic agent scopes).
  2. Resolve Allowed Set: The union of all permissions from these RoleBindings is compiled into an “allowed permissions” set.
  3. Apply AccessConstraints: The engine queries and loads all non-disabled AccessConstraints that apply to the principal (matching on direct principal ID, group memberships, or all_principals).
  4. Calculate Intersection: The effective permission set is the intersection of the resolved allowed set and the AccessConstraints’ maximum_permissions ceilings. If no positive RoleBinding grants the permission, or if an AccessConstraint excludes it, access is denied (fail-closed).

The Hub API and Web UI utilize a capability gating system. Resource responses from the API include _capabilities annotations. These annotations explicitly state the actions the authenticated user is permitted to perform on that specific resource. This ensures granular UI controls (e.g., disabling the “Delete” button if the user lacks permission) and provides a secondary layer of API-level enforcement.

The skills list (GET /api/v1/skills) also returns a list-level _capabilities object. It includes create when the caller can create a skill in at least one scope the request covers: any scope when no scope filter is given (every signed-in user can create in their own user scope), otherwise only the filtered scope and scopeId. Listing itself is not reported as a capability, because a successful response already means the caller may list.

To prevent lateral privilege escalation—where an agent with low privileges creates a child agent with high privileges, or a user assigns a highly privileged GCP service account they shouldn’t have access to—Scion implements a secure, two-layer gate for binding a GCP service account to any agent:

  1. Layer 1: Scion Hub Authorization: The Hub’s built-in authorization engine verifies the caller has the ActionAssign permission on the GCP service account resource within Scion. For project-scoped service accounts, the project-owner, project-admin and project-member roles hold gcp_service_account.assign, so every owner, admin and member of a project passes this layer for any project-scoped service account in that project.
  2. Layer 2: GCP IAM Policy (actAs): This layer runs only when gcp_iam_check_mode is set to enforce; in the default off mode, Layer 1 alone decides project-scoped assignment. enforce is strongly recommended, see the caution under GCP IAM Check Mode. Under enforce, the Hub evaluates Google Cloud’s IAM delegation model via the GCP Policy Troubleshooter v3 API. It verifies that the caller’s GCP principal possesses iam.serviceAccounts.actAs permission on the target service account.

The actAs (impersonation) check is critical because binding a service account to an agent grants that agent real cloud authority.

Layer Checked Authority Action Checked Principal
Hub Authorization Inside Scion ActionAssign Scion User/Agent
GCP IAM Inside Google Cloud iam.serviceAccounts.actAs Caller’s GCP Principal

Note: The Hub’s own roles/iam.serviceAccountTokenCreator permission is used to perform impersonated credential probes. It is NOT the permission checked on the caller. The permission evaluated on the caller is iam.serviceAccounts.actAs (typically granted via roles/iam.serviceAccountUser).

If Policy Troubleshooter returns an indeterminate or unknown status (e.g., ACCESS_STATE_UNKNOWN_CONDITIONAL due to IAM conditions, or ACCESS_STATE_UNKNOWN_INFO_DENIED due to insufficient Hub reviewer permissions), Scion fails closed and denies the assignment immediately. There is no fallback to getIamPolicy, which can easily fail open or miss complex project-level, group-level, or org-level bindings.

To maintain high API performance without violating security constraints, assignment decisions are cached using asymmetric TTLs:

  • Allow TTL: 60 seconds
  • Deny TTL: 10 seconds
  • Indeterminate / Error States: Never cached. Transient failures are retried immediately on the next request to prevent short outages from becoming fixed-length service blocks.

The cache is automatically invalidated for a target service account when that service account is deleted, or when a Hub-initiated IAM mutation occurs.

For Policy Troubleshooter to evaluate a caller’s IAM permission across the organization, the Scion Hub’s own GCP service account must be granted the IAM Security Reviewer role (roles/iam.securityReviewer) at either the Google Cloud project or organization level.

Hub Identity Access for the Assignment Check

Section titled “Hub Identity Access for the Assignment Check”

The Hub’s identity needs access to run the Policy Troubleshooter check against the project or organization that holds each target service account. Until it has that access, service account assignment is denied.

When check mode is enforce, the Hub’s service account needs roles/iam.securityReviewer in every GCP project that holds a service account you assign, not only in the Hub’s own project. A grant at the organization level covers all projects in it. For a service account in another project, grant the role on that project:

Terminal window
gcloud projects add-iam-policy-binding <service-account-project> \
--member="serviceAccount:<hub-service-account>" \
--role="roles/iam.securityReviewer"

If the role is missing there, assignment is refused with 403 and a message that names the service account’s GCP project, the Hub’s service account and the IAM Security Reviewer role, and gives this command with both values filled in. If the Hub does not know its own service account email, or the project ID or that email does not have the shape of a GCP project ID or service account email, the refusal instead uses the generic message below; grant the role as shown above. Retrying does not help until the role is granted. Other check failures, such as the Policy Troubleshooter API being disabled or a transient error, keep the message “Could not verify your permission to use this GCP service account because the check did not complete; try again”.

Registering or verifying a service account checks this ahead of time. When check mode is enforce and the account is in a GCP project other than the Hub’s own, the Hub asks Policy Troubleshooter about that project. If the Hub’s service account is refused there, or the project is not found, the response still succeeds and carries the same message, with the gcloud command, in its warnings list: registration returns it with 201, and verification only with 200, since a verification that fails returns its error without warnings. A project that does not exist and one the Hub may not read give the same response. The warning is advisory only: it does not block registration or change the account’s verification status. Other outcomes of that call, including a disabled API or a transient error, add no warning. Accounts in the Hub’s own project, and all accounts while check mode is off, are not checked this way.

If the check cannot run because of the Hub’s identity, that Hub instance reports the check sa_assign_check as degraded in its health row, and the admin health summary (GET /api/v1/admin/health/summary) reports it in a service_account_check section that lists the instances reporting it. Every Hub replica returns the same section.

An assignment that was valid when it was made can stop being usable later. Before the Hub starts, restarts or resumes an agent with an assigned service account (from the Web Dashboard, the API, or scion start / scion resume), it checks that the account:

  • still exists and is still reachable from the agent’s project;
  • is still verified, under the same email the agent was assigned;
  • for a hub-scoped account, is still allowed by gcp_iam_check_mode: enforce.

If any check fails, the start is refused with 400 (403 identity_assign_denied for a hub-scoped account the check mode no longer allows) and a message that says what to fix (for example, re-verify the service account or assign another one), instead of the agent starting and failing later when it asks for a token. If the Hub cannot complete the check, the start fails with 500. Stop and suspend are not checked. Every token request repeats the same check, so passing it at start does not exempt an agent later.

Each GCP token request from an agent (POST /api/v1/agent/gcp-token and POST /api/v1/agent/gcp-identity-token) is a new authorization decision for gcp_service_account.use, recorded in the decision audit log on every request. Besides the checks above, the principal recorded as the source of the agent’s service account assignment must still hold gcp_service_account.assign on that exact account at the time of the request, within the permission ceiling recorded with the assignment. The source is whoever made the assignment: the agent’s creator for an assignment made at creation (including one taken from a project or hub default), the caller of a PATCH or reincarnate that changed the account, or the schedule’s principal for a scheduled dispatch. A default selects an account but grants no authority. When gcp_iam_check_mode is enforce, the source must also still pass the actAs check on the account; an indeterminate result denies. In off mode the Hub policy check alone applies, as at assignment. The actAs check writes its own audit record, separate from the decision record: a request refused by actAs has an allowed gcp_service_account.use decision record followed by a denied actAs record.

If the source has lost gcp_service_account.assign on the account (for example, it was removed from the project, or from the hub’s members for a hub-scoped account), has been suspended or deleted, or the assignment has no recorded source (ceiling_provenance_missing), the request is refused with 403 and the message GCP service account use is not authorized. On upgrade, a one-shot startup migration (sa_assignment_legacy_backfill) creates assignment rows (assigned_via: legacy_backfill) for live assign-mode agents that predate assignment provenance and have a single active recorded delegation edge, pointing at that edge’s delegator and delegator_type. Agents skipped by the backfill (or whose recorded assigner later lost access) must be reincarnated with the same role by a signed-in user who holds gcp_service_account.assign, re-assigned from a principal that holds the permission, or recreated (see Service-account assignment backfill). A Cloud Run sandbox agent that runs as its Runtime Broker’s host service account follows the same rule: the user who requested passthrough is the recorded source and must hold gcp_service_account.assign on the host account; the Hub’s configured defaults and settings administrators are never substituted for it.

An agent whose own delegation chain has an unrecorded hop (an agent from before delegation provenance was recorded) is refused, even when its service account assignment has been recorded again since. Adopt its delegation (see Delegation provenance adoption) or recreate the agent. An agent with no delegation record of its own is admitted only before the Hub’s delegation-edge backfill has completed; after it, or when a parent agent in its chain has no delegation record (for example, because the parent was deleted), the request is refused. Recreate the agent from a live principal.

An access constraint on the agent also applies: a constraint whose maximum permissions do not include gcp_service_account.use refuses the token request.

An agent that is not in good standing (suspended by a hold, below a suspended or deleted parent agent, or rooted at a user who is inactive or no longer admitted to the project) is refused before this decision.

The same rule, standing included, applies when the Hub fetches a gs:// link from chat with the sending agent’s service account (see gs:// links in chat). A refused fetch looks like any other unavailable object.

If the Hub cannot save the result of a service account verification (on registration, minting or an explicit verify), the request fails with 500 instead of reporting success. On registration the account record already exists, so re-run verification on it (scion project service-accounts verify <id>, or POST .../gcp-service-accounts/<id>/verify) rather than registering it again.

Any project member, agents included, may see the email of a service account the project can use. A caller outside the project may not. The list endpoints apply this rule:

  • GET /api/v1/projects/<project>/gcp-service-accounts and GET /api/v1/gcp-service-accounts?scope=project&scopeId=<project> require read access to the project. Project members can list, and an agent can list only its own project’s accounts. Anyone else gets 403. With includeHubScoped=true the list also includes the hub-scoped accounts the project can use. An unknown project returns 404.
  • A user access token now needs the project:read scope to list a project’s accounts on either route; without it the list returns 403.
  • GET /api/v1/gcp-service-accounts?scope=hub requires hub membership: the hub-member and hub-viewer roles carry gcp_service_account.list. Users who are not hub members, and agents, get 403. An agent sees the hub-scoped accounts it can use through its own project’s list with includeHubScoped=true.

Error messages follow the same rule. A message names an account only when the caller already has access in the account’s project (for example while creating an agent there), or is a hub member and the account is hub-scoped. The “not available” message is the same for an account that does not exist and one the caller cannot see, so it never names an account.

GCP identity failures carry a stable code. The message names the step that failed, the scope (project, profile or hub) and who can fix it.

Code Status When Remedy in the message
identity_not_verified 400 (422 when setting the hub default) An agent is created, patched, reincarnated or started with an account the Hub cannot obtain tokens for. Also returned when a project default (400) or the hub default (422, like the hub settings’ other validation errors) is set to an unverified account. A project admin grants the Hub’s service account roles/iam.serviceAccountTokenCreator on the account, then verifies it (scion service-accounts verify <id>). A hub admin does this for a hub-scoped account (--global).
identity_default_invalid 400 A configured default (project, per-profile or hub) names an account that is not available in the project or not verified. Names the default (project default, project default for profile "<profile>" or hub default), the setting to change (defaultGCPIdentityServiceAccountID, the profile’s entry in defaultGCPIdentityServiceAccountIDByProfile, or agent_defaults.default_gcp_identity_service_account_id) and who changes it (a project admin, or a hub admin for the hub default).
identity_assign_denied 403 The caller may not assign the account. Also returned, still as 403, when an agent is started, restarted or reincarnated with a hub-scoped account that gcp_iam_check_mode no longer allows. Names what is missing: gcp_service_account.assign (a project owner or admin grants it by adding the caller to the project; for a hub-scoped account, a hub admin adds the caller to the hub’s members), iam.serviceAccounts.actAs on the account (an IAM admin of the account’s GCP project grants it, for example with roles/iam.serviceAccountUser), or gcp_iam_check_mode: enforce for a hub-scoped account (a hub admin sets it). Delegation-chain denials for agents keep their own messages.
identity_not_mapped, identity_ksa_mismatch 400 Kubernetes Workload Identity mapping errors. See assign on Kubernetes.
identity_ambiguous 400 A service account reference matches more than one account. Retry with one of the listed ids.

An account that does not exist, or that the caller cannot see from the project, is still refused with 400 validation_error and the same “not available” message as before.

Hub-scoped service accounts are defined globally at the Hub level rather than being restricted to a single project. This allows Platform Ops to make shared service accounts available for selection across multiple project-level workspaces.

Any current hub member may register an existing (bring-your-own) account at hub scope, with scion service-accounts add <email> --gcp-project <gcp-project> --global or the API. Registering does not make the account assignable on its own: assignment goes through the gates below.

To prevent unauthorized assignment of global resources, Scion applies specialized security logic:

  • Enforcement Mode Dependency: Assignment of a hub-scoped service account is unconditionally denied if gcp_iam_check_mode is set to off. Because “off” mode disables the GCP IAM validation layer, letting users assign global service accounts without an actAs check would create a massive security risk. Hub-scoped service accounts require gcp_iam_check_mode: enforce to be assigned.
  • Dynamic Membership Checks: Only current, active members of the Hub or project who possess ActionAssign on the resource and pass the Policy Troubleshooter actAs check can bind the service account.
  • Former-Member Denial: If a user is removed from a project or leaves the organization, they immediately lose the ability to assign those service accounts—even if they were the user who originally created or registered the service account record in Scion. Ownership-based bypasses do not apply to hub-scoped service accounts.

To streamline the agent creation workflow, project administrators can configure a project-default GCP service account that is automatically applied to newly created agents. However, to prevent privilege-escalation bypasses, this assignment is strictly gated:

  • Enforced at Creation and Selection: The Policy Troubleshooter actAs evaluation is automatically triggered whenever an agent is created using the project’s default service account, or when a user selects the default service account option.
  • Unauthorized Bypass Prevention: If a user does not possess iam.serviceAccounts.actAs permission on the project’s default service account, they are barred from creating agents under that project with the default identity, even if they have full project access.

A project can also set a default service account per broker profile, in the project settings API field defaultGCPIdentityServiceAccountIDByProfile (a map of profile name to registered service account ID, stored as the project annotation scion.io/default-gcp-identity-service-account-id-by-profile). This lets agents on a Kubernetes profile default to a service account that has Workload Identity set up (a kubernetes_service_account_mappings entry on that profile), while agents on other profiles keep the project-wide default.

  • Which profile counts: the profile the agent actually runs under. That is the profile in the create request, else the project’s active profile, else the broker’s own default profile (or its only profile). The Hub uses the same resolution as the hub-default passthrough check. When no profile can be resolved (for example, no request or active profile and a broker with several profiles and no default), this rung is skipped and the project default applies. The Hub pins the resolved profile on the agent, including the create inputs that reincarnate replays, so the broker dispatches under that profile. A reincarnated agent keeps the pinned profile, and so does a moved one (the move target must have that profile).
  • Profile names are not enforced against brokers: the map’s keys are not compared with the profiles brokers report, since a broker may be offline or not yet registered when the setting is saved. An entry for a profile no agent runs under is never used. Instead, saving an entry returns a warning (the save still succeeds) when no broker providing the project reports that profile, or when the account is not mapped to a Kubernetes ServiceAccount on that profile according to the broker’s latest report. No warning is given when the Hub cannot tell. Profile names may contain letters, digits, - and _, must start with a letter or digit, and are at most 63 characters; the map holds at most 64 entries.
  • Precedence: when the create request has no GCP identity, a matching entry assigns its service account (mode Assign). It wins over the project-wide default, including an explicit project Block, because it is the more specific setting. With no entry for that profile, or no map at all, the ladder below is unchanged.
  • Same gates: each entry must name a verified service account reachable from the project, and this is checked when the setting is saved. Only the entries a save adds or changes are checked, and removing an entry is always allowed, so a stale entry for one profile does not block changing another. Each agent creation runs the same creator actAs authorization as the project default, recorded under the project-default audit surface. An entry that has gone stale (the account was deleted or un-verified) fails the create with an error that names the profile, for example project default for profile "remote" GCP service account is not verified.
  • Updating: PUT /api/v1/projects/{id}/settings keeps the stored map when the field is absent, and clears it when the field is an empty object ({}). In the web UI, edit it under Project Settings > Configuration > Auth & Security > Per-profile default service accounts: one row per profile, choosing from the profiles the project’s brokers report and from verified service accounts. The page shows save warnings, and validation errors that name a profile, next to that profile’s row (an error that names no profile, such as an account that is not available in the project, is shown for the save as a whole), warns when a Kubernetes profile has no entry while another profile has one (its agents use the project default, which may not be mapped on that cluster), and lists the identity new agents on each profile get. Removing every row clears the map. Project clone remaps entries that name project-scoped accounts to the cloned accounts.
  • Debugging: the Hub logs at debug level which source chose the service account (project-profile-default, project-default, or hub-default).

Hub administrators can set a hub-wide default GCP identity in Admin > Server Config > Agent Defaults > General (agent_defaults.default_gcp_identity_mode and default_gcp_identity_service_account_id). The Hub picks the identity for a new agent from the first of these that is set:

  1. The GCP identity in the agent create request. Not applicable to an agent dispatched by a schedule, which carries no explicit identity.
  2. The project’s per-profile default service account for the profile the agent runs under (see Per-Profile Default Service Accounts).
  3. The project’s default GCP identity. An explicit project Block counts as set, so the hub default is not consulted.
  4. The hub default. An explicit hub default of Block counts as set too.
  5. Nothing configured at any rung: the Hub leaves the agent’s GCP identity unset and sends no mode, so the Runtime Broker applies its runtime default: Block on every runtime except Kubernetes, Passthrough on Kubernetes.

This ladder applies the same way whether the agent is created interactively/via the API or dispatched by a schedule: a scheduled dispatch starts at rung 2, and a hub default with no project-level override reaches it exactly as it would an interactive create.

Kubernetes runtime note: on the Kubernetes runtime, Block runs the agent pod as a zero-privilege identity rather than refusing it: the operator’s block Kubernetes ServiceAccount (kubernetes_block_service_account), or the namespace’s default ServiceAccount when none is configured, with no Kubernetes API token mounted and only on Workload Identity nodes. This applies whether Block comes from the create request, an explicit project default, an explicit hub default, or a project or hub default of Assign with no service account selected. Zero privilege depends on IAM preconditions the operator must keep true; see block on Kubernetes. Rung 5 (nothing configured) and a denied hub-default Passthrough (below) are not Block: they get the Kubernetes runtime default, Passthrough.

This also applies to agents created by earlier versions that wrote an explicit Block into an agent’s own stored GCP identity when the intent was “nothing configured”. That stored value is not migrated: such an agent starts on Kubernetes as a Block pod. Edit the agent’s own GCP identity mode to change it (a project or hub default only affects agents created after the change).

Passthrough on Kubernetes reaches the pod’s ambient identity (the node’s or the pod’s own Kubernetes ServiceAccount, whatever Workload Identity or the cluster otherwise provides). An explicit per-agent passthrough request goes through the broker-owner and host-SA checks. Operators who want the Hub to control which service account an agent on Kubernetes gets should configure Assign — at the project or hub default level, or per agent.

A hub-default Passthrough denied by either gate below — not the embedded Runtime Broker, or resolved to a non-local runtime profile — is treated the same as rung 5 on every runtime: the Hub leaves the agent’s GCP identity unset rather than writing an explicit Block, and the Runtime Broker applies its runtime default: Block on most runtimes, Passthrough on Kubernetes. An explicit per-agent passthrough request denied by its own equivalent check still fails with its own clear error — this only changes what the hub default ladder does when it can’t honor its own choice.

Assign on the Kubernetes runtime uses Workload Identity instead of the metadata emulator every other runtime uses for Assign: the pod runs as a Kubernetes ServiceAccount (KSA) already bound to the resolved GSA, and no SCION_METADATA_MODE=assign/GCE_METADATA_HOST override or in-pod emulator is used. The actAs and hub-scoped-service-account gates above are unchanged and evaluated exactly the same way regardless of runtime — they authorize who may be assigned a GSA, independent of how the runtime later establishes that identity. What differs on Kubernetes is a separate, broker-side requirement: the resolved GSA must have a KSA mapped to it in kubernetes_service_account_mappings (runtime or profile level, in the broker’s own global settings — this mapping is operator-configured only and is never read from a project’s settings), pre-provisioned by the operator — Scion does not create, annotate, or bind KSAs itself. Without a mapping, the broker uses the one KSA in the agent’s namespace annotated iam.gke.io/gcp-service-account with the GSA, if there is exactly one (this needs read-only list access to serviceaccounts in that namespace). A dispatch whose GSA has neither fails before any pod is created, naming the setting to add. See Running Scion on Kubernetes and setup-gcp.md §2i.

Cloud Run Instances (cloudrun) under hub.cloudrun_native_identity: when the hub.cloudrun_native_identity experiment is on (dispatched by the Hub in HubAgentDefaults.Experiments on create, start, and restart), the Hub decides native identity before calling the Runtime Broker:

  • Broker profile report and Hub precheck: for each runtimes.<name>.cloudrun profile, the Runtime Broker’s heartbeat report (mappingsReportVersion 3, mappingsComplete: true) sets cloudRunNativeIdentity: true, lists the lower-cased accounts from global runtimes.<name>.cloudrun.assignable_service_accounts in serviceAccountMappings with source: "cloudrun_allowlist", and reports whether global runtimes.<name>.cloudrun.block_service_account is set as the boolean cloudRunBlockAccount (the block account email itself is never reported; cloudrun-instances profiles report cloudRunNativeIdentity: false with no entries, and a broker with force_runtime reports incompleteReason: "force_runtime"). Before dispatching create, start, or restart to a Cloud Run profile, the Hub checks the stored profile report (which must be at version 3 or later, complete, and confirmed within the last 15 minutes) and refuses before contacting the broker with 422 identity_not_mapped (checkedBy: "hub_report") when:
    • details.reason: "cloudrun_account_not_attachable" — mode is assign and the service account is not in the profile’s reported cloudrun.assignable_service_accounts (details.serviceAccountEmail names it);
    • details.reason: "cloudrun_block_account_missing" — mode is block, unset (rung 5), or a hub-default passthrough (RequireLocalRuntime), and the profile does not report cloudRunBlockAccount: true;
    • details.reason: "cloudrun_passthrough_unavailable" — mode is explicit or project-default passthrough;
    • details.reason: "cloudrun_native_identity_unsupported" — the profile does not report cloudRunNativeIdentity: true (for example a cloudrun-instances runtime entry);
    • details.reason: "cloudrun_broker_upgrade_required" (mappingsReportVersion < 3), "cloudrun_profile_unknown", or "cloudrun_report_not_current" (missing, incomplete, or older than 15 minutes).
  • Hub native decision, recorded state, and token minting: the actAs and hub-scoped-service-account gates above run on the Hub the same way as on other runtimes. When the precheck passes on a profile with cloudRunNativeIdentity: true, the Hub records native_cloud_run: true on the agent (appliedConfig.native_cloud_run) before calling the broker (if the Hub cannot read the broker row or record the decision, the dispatch fails before any broker call), and sends the broker the native marker (gcp_identity.native_cloud_run: true on create; SCION_METADATA_NATIVE_CLOUDRUN=1 in resolved env on start and restart). native_cloud_run is cleared only after a confirmed stop or delete, so an agent started with a native Cloud Run identity keeps that record even if hub.cloudrun_native_identity is later turned off. While native_cloud_run is recorded on the agent (or the live decision is true), the Hub strips gcp:token:* scopes from the agent’s Hub tokens and refuses GCP token mint requests (POST /api/v1/agent/gcp-token and POST /api/v1/agent/gcp-id-token) with 403 forbidden ("the hub does not issue GCP tokens for agents on this Cloud Run Instances profile; use the instance's metadata server").
  • Runtime Broker enforcement:
    • Assign: requires the Hub’s native marker (gcp_identity.native_cloud_run on create, or SCION_METADATA_NATIVE_CLOUDRUN=1 with SCION_METADATA_MODE=assign, SCION_METADATA_MODE_SOURCE=hub, and SCION_METADATA_SA_EMAIL on start and restart; a request without the marker is refused with 400 "agent \"<name>\": the hub did not mark this dispatch for native Cloud Run identity"). The lower-cased email must be listed in runtimes.<name>.cloudrun.assignable_service_accounts in the broker’s global settings.yaml (empty or unset allows no account and fails with 400 "agent \"<name>\": GCP identity mode \"assign\" on Cloud Run Instances: account not attachable on this broker"; a project’s settings.yaml value is ignored with a warning, and global entries that do not match a lowercase *.gserviceaccount.com email log a warning by index). assign is also refused with 400 when the init firewall (SCION_INIT_FIREWALL) is enabled or set to an invalid value ("agent \"<name>\": the init firewall (SCION_INIT_FIREWALL) is an unsupported combination with Cloud Run native identity (hub.cloudrun_native_identity); remove one of the two"). The broker runs the instance as the assigned service account (Instance.ServiceAccount) and rewrites the container’s SCION_METADATA_MODE to passthrough (keeping SCION_METADATA_SA_EMAIL and SCION_METADATA_PROJECT_ID as informational environment variables) so the instance’s metadata server serves the assigned account directly without the in-container metadata emulator.
    • Block, unset (rung 5), and hub-default Passthrough: run the instance as runtimes.<name>.cloudrun.block_service_account from the broker’s global settings.yaml (a project’s settings.yaml value is ignored with a warning, and a global value that is not a lowercase *.gserviceaccount.com email logs a warning and is treated as unset), with SCION_METADATA_MODE=block inside the container and no SCION_METADATA_SA_EMAIL, SCION_METADATA_PROJECT_ID, or SCION_METADATA_NATIVE_CLOUDRUN. If block_service_account is unset, the broker refuses the dispatch with 400 ("agent \"<name>\": GCP identity mode \"block\" on Cloud Run Instances needs a block service account; set runtimes.<entry>.cloudrun.block_service_account on the broker").
    • Explicit or project-default Passthrough: refused with 400 ("agent \"<name>\": passthrough is not available on Cloud Run Instances; assign an account or use block").
    • Instance lifecycle and other Cloud Run shapes: the instance carries the scion-identity label (the first 16 hex characters of the SHA-256 of the lower-cased service account email), and on start or restart this run’s existing instance is deleted and recreated rather than started in place (dropping any cached IAP SSH exec key; an existing instance with no scion-run-id label is refused with a run conflict and left untouched). cloudrun-instances runtime entries and flat Cloud Run instances have neither assignable_service_accounts nor block_service_account and refuse assign and block with 400, and a start or restart whose resolved runtime or profile/runtime-entry selection changes after identity resolution is refused with 409. While hub.cloudrun_native_identity is off, the instance runs as cloudrun.service_account and assign uses the metadata emulator.

The hub default does not bypass the existing gates:

  • Assign: the service account must be verified and hub-scoped, and gcp_iam_check_mode must be enforce. These are checked when the setting is saved. Each agent creation also runs the same creator actAs authorization as project-default assignment, recorded under the audit surface hub-default. For a scheduled dispatch, the “creator” is the schedule’s immediate creator (the user or agent that created the schedule), the same principal the project-default rung already authorizes against on that path.
  • Passthrough: applies only when the agent is dispatched to the Hub’s embedded (co-located) Runtime Broker. The Hub identifies its embedded Runtime Broker by the ID it records when it starts it, not by the scion.io/broker-role label, because a Runtime Broker’s owner can set its labels. During startup the Hub API can accept requests before the embedded Runtime Broker has registered; an agent created in that window waits up to 15 seconds for registration rather than being denied immediately. If registration fails, the Hub has no embedded Runtime Broker until it restarts, and the Hub log says so on each affected create. This covers the single-node deployment. Even on the embedded Runtime Broker, passthrough also requires the agent’s resolved runtime profile to be a local container runtime (docker or podman) that shares the host’s metadata server. The profile is the one named in the request or the project’s active profile, falling back to the Runtime Broker’s default profile. A kubernetes profile, any other type (including Apple container), or a profile the Hub cannot resolve is denied this grant, as is any agent on a Runtime Broker other than the embedded one. In both cases the Hub leaves the agent’s GCP identity unset rather than writing Block, and logs why. The Runtime Broker re-checks the resolved runtime before it honors passthrough. When this rung leaves the identity unset, the agent gets the runtime default (Block, or Passthrough on Kubernetes). Without this limit, a hub-wide default would expose the host identity of every registered Runtime Broker to every agent creator. It would also skip the checks on the Runtime Broker’s owner and host service account that explicit passthrough requests go through.

In Passthrough Mode, an agent bypasses explicit service account binding and directly assumes the GCP identity of its GKE/GCE broker host. To prevent unauthorized access to host-level authority:

  1. Broker-Owner Restriction: The caller must own that specific Runtime Broker or be a Hub admin. On single-node deployments the Hub’s embedded Runtime Broker has no recorded owner, so a Hub admin signed in directly (not through a scoped token or a federated identity) counts as its owner. Every other Runtime Broker still requires ownership or admin rights.
  2. Host SA check: The caller’s GCP principal is checked via Policy Troubleshooter to confirm they hold iam.serviceAccounts.actAs permission on the broker’s underlying host service account.

To enforce this boundary reliably, Scion implements strict PATCH Parity across its API:

  • Previously, the actAs check only ran on agent creation (POST /api/v1/agents).
  • Now, the exact same validation function gates the update path (PATCH /api/v1/agents/{id}). This prevents users from sneaking past the delegation gates by creating a low-privilege or no-auth agent and then PATCHing it to use passthrough mode.

Minting is the process where Scion Hub automatically provisions a brand-new GCP service account in the Hub’s own project and registers it to the database on behalf of the user. Because minting creates new GCP authority and project IAM bindings, it operates under a highly secure flow:

  • Enforced Regardless of Mode: Unlike assignment checks (which can be toggled via gcp_iam_check_mode), minting checks are always active. SAs cannot be minted unless the requester passes GCP IAM checks, even if gcp_iam_check_mode is set to off. Skipping mint checks would create an instant privilege-creation bypass.
  • Required GCP Permissions: To mint a service account, the requester’s GCP principal must have:
    • iam.serviceAccounts.create on the Hub’s GCP project (to create the service account).
    • aiplatform.endpoints.predict on the target project (to authorize the minted SA to access the GCP Vertex AI Platform).
  • Fail-Closed Minting Flow: A minted service account is stored as Verified in Scion only if all required downstream GCP IAM mutations succeed—specifically, granting the Hub SA roles/iam.serviceAccountTokenCreator on the minted SA, and granting the requester roles/iam.serviceAccountUser on the minted SA. If any mutation fails, the status is recorded as failed and the service account remains unverified.

To make the service account lifecycle transparent and auditable for users and administrators, the Web Dashboard includes the following enhancements:

  • Tiered Role Badges: The agents list and agent detail pages display visible role badges (none, readonly, baseline, or full) highlighting the active execution role of each running container.
  • GCP Identity Card: The agent detail view’s Configuration tab includes a GCP Identity Card. It shows the metadata mode (Assign, Passthrough, or Block). For an assigned service account, it also shows the account’s display name, with its email as secondary text, plus the GCP project ID and a verification status: Verified, Not verified (hover for the reason if the last verification failed), or Unknown when the page cannot load the registered account record. Verification status appears only for assigned accounts; passthrough and block agents have no registered account to verify.
  • Service Account Status Manager: Within project settings, owners can view registered service accounts, check their live Policy Troubleshooter verification status, and manually trigger verification probes.
  • Zero-Reload Service Account Dropdown Sync: The UI dispatches custom events (sa-list-changed) across components upon SA registration, verification, minting, or deletion, instantly updating default service account selection dropdowns without a full-page reload, and automatically clears the default SA selection if the selected SA is deleted.

The Scion Hub enforces resource consumption through a strict Quota System (Permissions Phase 2). This system operates at both the project and agent creation layers:

  • Enforcement Mechanics: Quotas are evaluated via advisory-lock-based enforcement with fail-closed semantics to ensure hard limits are respected and reservation leaks are prevented.
  • Data Model: The quota system uses LimitDefinition, EntitlementBinding, and UsageReservation schemas backed by a dedicated QuotaStore.
  • System Limits: Several seeded system limit definitions provide out-of-the-box safe bounds on resource usage.
  • Quota API: A suite of 13 quota API endpoints is available for inspecting and managing quotas. These endpoints feature strict route guard read/write permission splits and built-in protection against arbitrary system limit modification.

To simplify management, Scion separates roles into User Roles (for human operators) and Agent Roles (for running agents).

These built-in roles bundle common permissions for human users:

Role Description
super-admin Full platform administrator with all permissions (System Role).
hub-admin Hub administrator with scopeable admin permissions (System Role).
hub-member Standard user; read access to directory resources, can create their own projects and register Runtime Brokers (broker.create) (System Role).
hub-viewer Read-only access to directory resources (System Role).
global-catalog-author Non-admin global skill authoring; grants only skill.create_global (System Role).
project-owner Full project permissions, including agent lifecycle, messaging and forwarded-port access. Does not include agent.attach on other members’ agents.
project-admin Like project-owner, but without agent.delete or agent.set_message_mode.
project-member Basic project permissions, including posting in the project’s chat threads (project.chat_post).
project-viewer Read-only project membership (agent.list, agent.read, project.list, project.read): can read the project, its agents, and its chat threads, and delete their own chat messages (delete_own), but cannot post messages, create or change threads or thread groups, edit messages, or create agents. Assignable to users, groups, and agents by project owners and project admins. At Hub startup, any non-system custom role in project scope named project-viewer is renamed by appending -custom- and the first eight characters of its role ID (auditing builtin_role_name_custom_role_renamed) before the built-in system role is seeded; it keeps its permissions and bindings and stays a custom role.
project-visitor Message, list and read the project’s agents (agent.message, agent.list, agent.read). Held only by federated accounts through a visitor grant, which is part of the experimental federation.project_visitors feature. It is not project membership: a visitor is not listed as a member and does not count as one in any membership rule. It cannot be assigned through the members editor or the role binding API.

The hub-admin role is at revision 10. Revision 10 adds agent.delegation.revoke_all, so a hub admin can revoke every agent delegation grant of an agent in one call; project owners and admins revoke project-bounded grants one at a time. Revision 9 adds project.chat_post, so a hub admin keeps posting in any project’s chat threads. Revision 8 adds project.manage_visitors, so a hub admin can list, shorten and revoke visitor grants on any project. Granting visitor access or extending a grant’s window also needs every permission of the project-visitor role on that project, which hub-admin alone does not give. Revision 7 adds agent.delegation.revoke, agent.delegation.credential.revoke, agent.delegation.list, and agent.delegation.read.

Chat access in a project space and its threads follows explicit capability rules evaluated per caller:

  • Reading a project space and its threads (read): requires project.read on the project (held by project-owner, project-admin, project-member, project-viewer, and custom roles with project.read).
  • Deleting your own message in a project thread (delete_own): requires project.read on the project (plus inbox:write when authenticating with a user access token), so a read-only member (project-viewer, or a custom role with project.read and without project.chat_post) can still delete messages they sent earlier.
  • Posting and mutating threads or thread groups (post, create_thread, create_group, rename, delete, set_default_agent, add_participant, edit_own): requires project.chat_post on the project (evaluated on the caller’s user account when using a user access token, while the token itself must carry inbox:write). The built-in project-owner, project-admin, and project-member roles and the hub-admin and super-admin system roles hold project.chat_post; project-viewer does not. Specifically, project.chat_post is required for:
    • Posting in a project thread, scheduling a message in a project thread, uploading chat attachments to a project space, promoting an agent DM into a project thread, or sending a message to an agent into a thread by thread key.
    • Creating a project thread (create_thread) or a project group conversation (create_group).
    • Renaming a thread (rename), changing its default agent (set_default_agent), deleting a thread (delete), or adding a participant to a project conversation (add_participant).
    • Editing your own message in a project thread (edit_own).
  • Setting a project space’s emoji (set_emoji): requires project.update on the project (and for a user access token, inbox:write for the project).
  • Agents in project threads: an agent caller is never granted thread post or mutation authority through project.read; agent posts in threads are governed by the agent’s message mode (project and hub modes allow; lineage and branch allow only an existing participant; none denies).
  • Direct messages (DMs): do not use project.chat_post.

When an action gated by project.chat_post is denied, the API responds with 403 and error code chat_post_denied (details.resource_type set to project, details.denied_action set to chat_post, details.reason set to read_only when the account lacks project.chat_post or token_scope when a user access token lacks inbox:write, plus details.chat_action naming the attempted action on thread, space, or message mutations; messages: “You can read this thread but not post in it”, “You can read this thread but not change it”, or “You can read this project’s chat but not create threads or groups in it”).

Each space entry in /api/v1/chat/topics and each conversation in /api/v1/chat/conversations/ message history includes _capabilities with actions and optional reason (read_only or token_scope; the message history response also keeps canPost for post). Space actions come from read, create_thread, create_group, and set_emoji; thread actions come from read, post, rename, delete, set_default_agent, add_participant, edit_own, and delete_own. When write actions are withheld, reason is read_only (the account lacks project.chat_post, so the web UI hides thread/group creation and thread-management controls, hides Edit message while keeping Delete message on the user’s own messages, and shows “You can read this thread, but you don’t have permission to post in it.” in place of the composer) or token_scope (a user access token lacks inbox:write, which also withholds delete_own, and the composer shows “You can read this thread, but the access token you’re using can’t post in it.”).

With a user access token, project.chat_post is checked on the token holder’s account; the token itself needs inbox:write to post or mutate chat. The project:chat_post token scope does not post; it keeps project.chat_post on the token so that a token with project:manage can grant roles that include it, such as project-member. Scheduled chat messages are checked again when they are sent, and fail with no_access if the sender can no longer post.

Every user has one hub role: admin, member or viewer. It is shown in Admin > Users and as a badge on the user’s own profile page. The hub role decides what a user can do across the whole hub. The hub grants it through the system roles above:

Hub role Granted through What it allows
admin A system-scope super-admin binding Full administrative access to the hub.
member Membership of the hub-members group, which holds the hub-member role Read the hub directory and catalogs (users, groups, templates, harness configs, brokers, skills, and so on), create projects and register Runtime Brokers.
viewer A system-scope hub-viewer binding The same as member, but cannot create projects or register Runtime Brokers. This includes cloning a project.
  • Project roles are independent of the hub role. A viewer can still be added to a project, and then works in it according to their project role (project-member, project-admin or project-owner). The hub role only controls hub-level actions, such as creating a project.
  • New users get the hub role set by server.auth.default_user_role (member unless configured otherwise). It is applied when the account is first created or activated, which includes the first sign-in of an invited or allow-listed user. Invites and allow-list entries carry no role of their own. Users listed in admin_emails are always admins.
  • Changing the default does not change existing users. To change an individual user’s role, use Change role in the actions menu on Admin > Users, or PATCH /api/v1/users/{id} with {"role": "viewer"} (admin, member or viewer). A pending invite has no role yet, so its role cannot be changed until the user has signed in.
  • Role changes take effect immediately. The hub updates the user’s group membership and role bindings when the role changes, whether an admin changes it or it changes at sign-in. No hub restart is needed.
  • The UI hides controls the user’s hub role does not allow. For example, a viewer does not see Create Project.

server.auth.default_user_role is not the same setting as server.federation.trusted_issuers[].default_role, which sets the role for users who authenticate with federated OIDC tokens.

Scion implements a dedicated, tiered authorization model for agents. This ensures that running agents only possess the specific permissions they need to interact with the Hub API.

Agents are assigned one of four named roles, each mapping to a fixed set of JWT scopes:

Agent Role Granted Scopes Description
none None No access to the Hub API (runs with no authorization claims).
readonly project:read Can view and query project state, but cannot report status, register port forwards, or manage other agents.
baseline project:read
agent:status:update
agent:token:refresh
project:agent:notify
agent:port:forward
Standard execution permissions. Allows the agent to report progress, refresh its token, register reverse-proxied port forwards, send notifications, and manage its own notification subscriptions.
full All baseline scopes +
project:agent:create
project:agent:sa_assign
project:agent:lifecycle
project:secret:read
Complete agent control. Allows spawning child (sub) agents, assigning GCP service accounts to agents, managing their lifecycles, and reading project-scoped secrets from the secret backend.

The effective role granted to an agent at creation depends on the caller:

$$\text{user dispatch} = \min(\text{requestedRole}, \text{projectMax})$$

$$\text{sub-agent dispatch} = \min(\text{requestedRole}, \text{parentRole}, \text{projectMax})$$

  1. Requested Role: The role requested during agent dispatch (e.g., using the --role flag in the CLI). For user dispatches, an omitted role defaults to the project-level or Hub-level default_agent_role. For sub-agent dispatches, it inherits the parent agent’s role.
    • Default Role Update: For better usability, the default fallback role has been changed from baseline to full.
    • Configuration Options: You can specify default_agent_role globally under agent_defaults in the Hub settings (via settings/admin UI) or customize it per-project using the admin UI dropdown or the project setting scion.io/default-agent-role.
  2. Project Max: Set by the project’s max_agent_role setting, which defaults to the global Hub configuration (default_max_agent_role under agent_defaults).
  3. Parent Role: For sub-agent dispatches, the child cannot exceed the parent agent’s stored role. Explicit over-requests are rejected with 403 Forbidden.

The live delegation check separately requires the caller to hold agent-creation authority in the target project.

To guard against unauthorized escalations, the role fallback chain and parent lookup enforce fail-closed behavior:

  • Parent Agent Lookup Failure: If parent agent lookup fails (e.g., due to transient database issues or invalid parent ID) when spawning a sub-agent, the sub-agent role ceiling defaults to baseline instead of failing open.
  • Corrupted Stored Roles: If a parent agent’s stored role is corrupted or invalid, it is treated as baseline for sub-agent creations to ensure robust security.

To prevent security bypasses via sub-agent creation, Scion enforces strict no-escalation rules:

  • When a parent agent spawns a child (sub-agent), the parent agent acts as the requester.
  • A parent agent cannot grant a child agent a higher role than its own.
  • Any attempt by an agent to spawn a child with elevated permissions will result in a loud, immediate 403 Forbidden API rejection.

To ensure security policies stay up-to-date and to support legacy agents created prior to the tiered role rollout, the Hub re-derives permissions from the agent’s stored role during token refresh (RefreshAgentToken), rather than copying old JWT scopes verbatim.

  • Legacy Agent Compatibility: Legacy agents that do not have a stored role default to the full role. This prevents production regressions where standing agents lose modern required scopes (such as project:read, project:agent:lifecycle, or secret access) after a token refresh.

With the introduction of tiered agent roles, the raw template field hubAccess.scopes no longer has any effect: the Hub does not read it from templates or record it on agents, and the CLI warns when a local template file still sets it. Agent permissions are configured via the named roles.

The permissions system features:

  • Identity Resolution: Core identity and domain-based authorization.
  • Capability Gating: UI and API enforcement via _capabilities.
  • Policy Enforcement: Strict authorization for agent creation, interaction, and deletion based on project membership and ownership.
  • Agent Identity & Ancestry: Strict scoping of agent names, ancestry chains, and transitive access control.
  • Group & Policy Management: Full support for group and policy schemas in the database, manageable via the Web Dashboard.

Scion enforces a robust security model for agent-to-agent interactions (progeny) through Ancestry Chains and Transitive Access Control.

When an agent creates a child agent (for example, to delegate a sub-task), the system records an ancestry chain (root → parent → child). This chain is used to enforce strict identity scoping and transitive access permissions.

  • Transitive Access: Any principal (human user or agent) that exists in an agent’s creation chain automatically gains access to manage that agent. If a user owns the root agent, they inherently have access to all of its descendants.
  • Strict Scoping: Agent identities are strictly scoped by their project using a specific naming convention (e.g., project--agent). This prevents name collisions across different workspaces and ensures that progeny agents cannot impersonate or interfere with agents in other projects.
  • Granular Secret Access: Progeny agents inherit granular secret access controls from their parents, ensuring they only have the credentials necessary to perform their specific tasks.

The Scion Web Dashboard includes a centralized Admin Management Suite (accessible to users with appropriate administrative capabilities) that provides dedicated views for access control and infrastructure management:

  • Server Configuration Editor: A full-featured settings editor at /admin/server-config. This allows administrators to view and modify the global settings.yaml through the Web UI with support for tabbed navigation, sensitive field masking, and hot-reloading of key settings like log levels, telemetry defaults, and admin emails.
  • Users List: View all authenticated users, search for specific accounts, track “Last Seen” timestamps, and manage their system-wide roles (e.g., granting hub-admin access). Administrators can also revoke all active sessions for a user, forcing immediate re-authentication across all devices (see Session Revocation below).
  • Groups Management: Full-featured admin UI/UX for creating and managing custom membership groups. Administrators can easily define hierarchical collections of users and manage their membership using a human-friendly editor with user search autocomplete. Group creation is strictly authorized, and the project: prefix is a reserved slug. To prevent slug collisions, colliding group identifiers require a system marker combined with the ProjectID. Membership lookups rely on canonical identity resolution. This enables policy-based authorization where permissions can be granted to an entire team at once, while strictly enforcing group ownership and authorization rules.
  • Access Boundaries: Full-featured administrative suite for defining and managing monotonic permission ceilings (AccessConstraints) via the Hub Admin UI.
    • Inventory Page: Provides a centralized view of all active and disabled access boundaries configured on the Hub.
    • Guided Authoring Workflow: A step-by-step UI workflow for creating and editing boundaries, including targeting individual principals, group closures, or all principals, and specifying allowed maximum permissions.
    • Preview Engine: An interactive evaluation sandbox enabling administrators to simulate, dry-run, and verify the impact of an access boundary on a principal’s effective permissions prior to committing. Backed by the Provenance/Explain API, it details exactly which positive permissions are restricted and why.
    • Transactional Governance: Access boundary changes are transaction-bound, so a failed change leaves no partial write.
    • Audit History: Each boundary’s detail page shows an audit timeline backed by GET /api/v1/admin/access-constraints/{id}/audit (newest first, cursor-paged). Creating or updating a boundary writes its history entry in the same transaction as the change, so no create or update commits without its entry. The history is readable while the boundary exists; the Hub keeps the most recent 1,000 entries per boundary, so the oldest entries, including the creation entry, are pruned beyond that. Deleting a boundary deletes its history: the delete emits only a best-effort structured log event and has no persisted audit record.
  • Role & Binding Management: Full CRUD interfaces for Role Definitions and Role Bindings. Administrators can define custom roles, map permissions, and bind them to users, groups, or agents at the Hub or Project scope, while the system enforces CanDelegate checks to prevent privilege escalation. Project owners can also assign and remove existing custom project roles for users and groups on their own project from the project’s Members editor, alongside the member’s built-in role (owner, admin or member); each save is applied atomically and audited, and the same CanDelegate ceiling applies, so an owner can only grant permissions they hold themselves. Project admins see custom roles read-only. Custom roles can’t be granted to agents from the Members editor; hub administrators can still bind them through the admin Role Bindings page. In the Members editor’s add dialog, a role you can’t assign is shown disabled with the reason. The Source column appears only when some access in the view comes from somewhere other than a direct binding, such as a group. On a project’s members group, the group owner can’t be changed.
  • Quota Management: Dedicated admin view to manage the Quota System. Administrators can view, create, and update LimitDefinition thresholds and monitor EntitlementBinding status across projects.
  • Admin Security & Navigation: The dashboard uses a Per-Resource Permission-Gated Admin UI to render and restrict access to the Admin Suite. Instead of a binary role===admin check:
    • Nav and route guards use granular, per-item permission checks.
    • The admin status API endpoint returns a per-resource permissions array that determines what elements are active and visible in the Admin UI.
    • Settings page tabs are gated by the caller’s actual resource-level permissions. For example, a role with template-only permissions (like template.*) sees only the Templates tab, while other administrative tabs are hidden.
  • Broker Visibility: Comprehensive broker detail pages provide a grouped view of all active agents by their respective projects, helping administrators understand resource distribution.
  • Maintenance Mode: Administrators can toggle maintenance mode for the Hub and Web servers directly from the UI to facilitate safe infrastructure updates.

By leveraging these administrative views, Platform Ops can efficiently map their organization’s structure directly into Scion’s Principal and Policy hierarchy.

Administrators can force any user to re-authenticate by revoking all of their active sessions. This is useful when a user’s credentials may be compromised, when an account needs to be immediately locked out, or after a security incident.

Each user record carries a session_generation counter. When an admin revokes a user’s sessions, the counter is incremented. On every subsequent web request, the Hub middleware compares the counter stored in the user’s session cookie against the database value. If the database value is higher, the session is invalidated immediately and the user is redirected to re-authenticate.

  • Web Dashboard: On the Admin Users page, open the actions menu for a user and select Revoke Sessions. A confirmation dialog appears; on confirmation the revocation takes effect immediately.
  • API: POST /api/v1/users/:id/revoke-sessions (requires admin privileges).

Session revocation affects cookie-based web sessions only. Agent tokens and User Access Tokens (UATs) are managed through their own revocation mechanisms.

If all admin users have been removed or an organization has lost administrative access to the Hub, the scion admin promote CLI command provides an emergency recovery path. This command connects directly to the database — bypassing the running Hub server — and promotes an existing user to the admin role.

Terminal window
scion admin promote --email user@example.com

The target user must already exist in the database. See the CLI Reference for the full command syntax and flags.

Users listed in the admin_emails server setting are always admins: they are promoted to admin when they sign in. When the list is non-empty, removing an email from it demotes that admin to the hub’s default role for new users (server.auth.default_user_role) at the next hub restart or their next sign-in, whichever comes first. Their permissions change at once. At restart, both admin_emails and the default role come from settings.yaml or the environment, so a change made only in the Admin UI (Postgres mode) takes effect at the user’s next sign-in. If the default role was set only in the Admin UI, a user demoted at restart becomes Member.

There are two exceptions:

  • UI-promoted admins keep admin. A user promoted to admin through the Web Dashboard (the Users list) or the users API holds admin because of that explicit action, not because of admin_emails. Removing their email from admin_emails does not demote them. To remove their admin role, change it on Admin > Users.
  • The startup safety check must have passed. When the hub starts, it checks that the admin_emails from its startup configuration (settings.yaml or the environment) matches at least one existing user, or that at least one UI-promoted admin exists. If not, the hub refuses all demotions, both at startup and at sign-in, until the configuration is fixed and the hub is restarted. This stops a configuration mistake from removing every administrator.