Problem Statement
Today, granting Platform Admin privileges on an OpenShell gateway requires the IdP to assign a specific role (e.g. openshell-admin) in the JWT claims. There is no way to designate a specific OIDC subject (sub claim) as a Platform Admin directly in the gateway configuration. This creates friction when bootstrapping a new gateway (chicken-and-egg: you need an admin to configure roles, but the admin role must come from the IdP), when the IdP doesn't support custom roles, or when an operator wants gateway-local admin overrides independent of IdP role assignments.
A gateway-level admin_subjects configuration option would grant Platform Admin to specific OIDC subjects regardless of their JWT role claims, working alongside the existing role-based admin check — either mechanism grants admin access.
Technical Context
The gateway's authorization system has two layers that enforce admin status:
- Middleware-level RBAC (
AuthzPolicy.check()) — evaluates whether an authenticated identity's roles include the configured admin_role to gate access to platform-admin-scoped gRPC methods.
- Handler-level workspace authorization (
workspace_authz::is_platform_admin()) — called directly by gRPC handlers to bypass workspace membership checks for platform admins.
Both layers currently check only the identity's JWT roles against the configured admin_role string. The OIDC subject (sub claim) — already available on the Identity struct — is never consulted for admin status determination.
Affected Components
| Component |
Key Files |
Role |
| Config (core) |
crates/openshell-core/src/config.rs |
OidcConfig struct — holds OIDC settings including admin_role |
| CLI args |
crates/openshell-server/src/cli.rs |
Clap arg definitions, env var bindings, config-file overlay logic |
| Server state |
crates/openshell-server/src/lib.rs |
ServerState — carries admin_role: String used by all handlers |
| AuthzPolicy (middleware) |
crates/openshell-server/src/auth/authz.rs |
AuthzPolicy.check() — method-level RBAC using roles |
| Workspace authz (handler) |
crates/openshell-server/src/auth/workspace_authz.rs |
is_platform_admin(), require_platform_admin(), authorize_workspace() |
| Multiplex layer |
crates/openshell-server/src/multiplex.rs |
Builds AuthzPolicy from OIDC config for the gRPC auth middleware |
| gRPC handlers |
crates/openshell-server/src/grpc/{sandbox,workspace,service,provider,policy}.rs |
Pass &state.admin_role to authorization functions |
| Inference handlers |
crates/openshell-server/src/inference.rs |
Also passes &state.admin_role to authorization functions |
| Helm chart |
deploy/helm/openshell/values.yaml, templates/gateway-config.yaml |
OIDC config rendered into gateway.toml |
| Docs |
docs/reference/gateway-config.mdx, docs/reference/gateway-auth.mdx |
Gateway config reference and auth guide |
Technical Investigation
Architecture Overview
Authentication and authorization are separated per RFC 0001. Authentication providers (OIDC, mTLS, etc.) produce an Identity with subject, display_name, roles, and scopes. The authorization layer then evaluates this identity without knowing which provider authenticated the caller.
The admin check flows through two paths:
Path 1 — Middleware RBAC (authz.rs):
OidcConfig.admin_role → AuthzPolicy.admin_role (constructed in multiplex.rs:287) → AuthzPolicy.check() compares identity.roles against admin_role to gate platform-admin-scoped gRPC methods.
Path 2 — Handler workspace authorization (workspace_authz.rs):
OidcConfig.admin_role → ServerState.admin_role (set in lib.rs:208) → passed as &state.admin_role to authorize_workspace() / require_platform_admin() → calls is_platform_admin(identity_roles, admin_role) which checks admin_role.is_empty() || identity_roles.iter().any(|r| r == admin_role).
Both paths have access to Identity.subject but never check it for admin determination.
Code References
| Location |
Description |
crates/openshell-core/src/config.rs:522 |
OidcConfig struct — #[serde(deny_unknown_fields)], holds admin_role: String |
crates/openshell-core/src/config.rs:541 |
admin_role field with default_admin_role() → "openshell-admin" |
crates/openshell-server/src/lib.rs:176 |
ServerState.admin_role: String field |
crates/openshell-server/src/lib.rs:205-208 |
admin_role populated from config.oidc.admin_role in ServerState::new() |
crates/openshell-server/src/auth/authz.rs:30 |
AuthzPolicy struct with admin_role: String |
crates/openshell-server/src/auth/authz.rs:75-95 |
AuthzPolicy.check() — role-based method authorization |
crates/openshell-server/src/auth/workspace_authz.rs:170-172 |
is_platform_admin() — core admin check: admin_role.is_empty() || identity_roles.iter().any(|r| r == admin_role) |
crates/openshell-server/src/auth/workspace_authz.rs:149-157 |
require_platform_admin() — called by handlers for cross-workspace ops |
crates/openshell-server/src/auth/workspace_authz.rs:73-86 |
authorize_workspace() — checks is_platform_admin() to bypass membership |
crates/openshell-server/src/multiplex.rs:287-290 |
AuthzPolicy construction from oidc config |
crates/openshell-server/src/cli.rs:172-175 |
CLI --oidc-admin-role arg, env OPENSHELL_OIDC_ADMIN_ROLE |
crates/openshell-server/src/cli.rs:659-673 |
Config file overlay applies TOML admin_role to CLI args |
deploy/helm/openshell/values.yaml:305 |
oidc.adminRole Helm value |
deploy/helm/openshell/templates/gateway-config.yaml:103-104 |
Renders admin_role into [openshell.gateway.oidc] TOML section |
Current Behavior
The core admin determination happens in workspace_authz.rs:
fn is_platform_admin(identity_roles: &[String], admin_role: &str) -> bool {
admin_role.is_empty() || identity_roles.iter().any(|r| r == admin_role)
}
When admin_role is empty (OIDC not configured or auth-only mode), every authenticated user is treated as platform admin. Otherwise, the user must have the admin_role in their JWT roles claim.
At the middleware level, AuthzPolicy.check() similarly compares identity.roles against admin_role to determine if the caller can access platform-admin-scoped methods. Admin role holders implicitly satisfy user-role requirements.
There are ~30+ call sites across grpc/sandbox.rs, grpc/workspace.rs, grpc/service.rs, grpc/provider.rs, grpc/policy.rs, and inference.rs that pass &state.admin_role to authorize_workspace(), authorize_sandbox_workspace(), or require_platform_admin().
What Would Need to Change
Config layer (openshell-core): Add admin_subjects: Vec<String> to OidcConfig. The deny_unknown_fields serde attribute means this must be an explicit field.
CLI layer (openshell-server/cli.rs): Add --oidc-admin-subjects CLI arg with OPENSHELL_OIDC_ADMIN_SUBJECTS env var. Add config-file overlay logic in the same pattern as admin_role.
Server state (openshell-server/lib.rs): Add admin_subjects field to ServerState, populated from config.oidc.admin_subjects in ServerState::new().
Middleware RBAC (authz.rs): Add admin_subjects: HashSet<String> to AuthzPolicy. Modify check() to also grant admin access when identity.subject is in admin_subjects.
Handler authorization (workspace_authz.rs): Modify is_platform_admin() signature to accept the caller's subject and the admin_subjects set. Update require_platform_admin(), authorize_workspace(), and is_platform_admin_principal() accordingly.
Call site churn mitigation: The ~30+ call sites that pass &state.admin_role would need updating. A recommended approach: introduce an AdminPolicy struct (or similar) that bundles admin_role and admin_subjects, then change ServerState to carry admin_policy: AdminPolicy and update the authorization function signatures to take &AdminPolicy. This localizes the change and prevents future churn if more admin criteria are added.
Multiplex (multiplex.rs): Update AuthzPolicy construction to include admin_subjects.
Helm chart: Add adminSubjects: [] to values.yaml under server.oidc. Render as admin_subjects = ["sub1", "sub2"] in the gateway-config.yaml template.
Docs: Update docs/reference/gateway-config.mdx and docs/reference/gateway-auth.mdx for the new config field, CLI flag, and env var.
Alternative Approaches Considered
-
Flat approach — add admin_subjects as a sibling field to admin_role in all signatures. Simpler to implement but results in ~30+ call-site changes and makes future admin-criteria additions equally expensive.
-
AdminPolicy struct — bundle admin_role + admin_subjects into one struct, thread that through instead of separate fields. More upfront work but reduces call-site churn to a type change and prevents future N+1 field threading. Recommended.
-
Synthetic role injection — when the subject matches admin_subjects, inject the admin_role into the identity's roles list at the authentication layer, before authorization runs. This would require zero changes to the authorization layer but mixes concerns: the auth layer would be modifying identity claims, which could confuse audit logging and GetCurrentUser responses. Not recommended.
Patterns to Follow
- Config fields: Follow the
admin_role pattern — #[serde(default)] with a default function, matching CLI arg + env var + config-file overlay.
- CLI env vars: Use
OPENSHELL_OIDC_ADMIN_SUBJECTS with comma-separated values (consistent with how list-type env vars are handled elsewhere).
- Helm values: Follow the existing OIDC block pattern with conditional rendering in the template.
- Tests: The
workspace_authz.rs test module has thorough coverage of admin scenarios. New tests should follow platform_admin_bypasses_membership_check and auth_disabled_empty_admin_role_is_platform_admin patterns. authz.rs has role-based tests that should be extended for subject-based admin access.
Proposed Approach
Introduce an AdminPolicy struct that bundles the admin role name and admin subjects set, replacing the flat admin_role: String threading. Add admin_subjects to OidcConfig (config), CLI args (with env var OPENSHELL_OIDC_ADMIN_SUBJECTS), and the Helm chart. Modify is_platform_admin() and AuthzPolicy.check() to grant admin access when the caller's OIDC subject is in the configured admin_subjects set, alongside the existing role-based check. Update gateway config and auth documentation.
Scope Assessment
- Complexity: Medium — well-scoped changes across multiple files, but the pattern is clear and mechanical
- Confidence: High — clear path forward, no architectural unknowns
- Estimated files to change: ~15 (config, CLI, server state, 2 auth modules, multiplex, Helm values + template, 2 docs pages, plus test updates)
- Issue type:
feat
Risks & Open Questions
- Revocation latency: Admin subjects are static in gateway config — revoking an admin subject requires a gateway restart (or config reload if supported). Should be documented as an operational consideration.
- Audit trail: When a user is granted admin via
admin_subjects rather than IdP roles, the GetCurrentUser response won't show an admin role in roles[]. Operators may want to distinguish how admin access was granted. Consider whether the auth grant path should be surfaced (e.g. in logs or response metadata).
- Auth-only mode interaction: When both
admin_role and user_role are empty (auth-only mode), everyone is already admin. admin_subjects should be a no-op in that mode — document this.
- CLI arg format: Comma-separated
OPENSHELL_OIDC_ADMIN_SUBJECTS=sub1,sub2 or multi-value --oidc-admin-subject sub1 --oidc-admin-subject sub2? Clap supports both; comma-separated env var + multi-value CLI flag is the most ergonomic.
- Large subject lists: For gateways with many admin subjects, a
HashSet<String> is appropriate for O(1) lookup. The current role check is O(n) on roles which is fine for small role lists.
Disposition Readiness
- State: validated
- Assessment: The investigation identified all affected code paths, confirmed feasibility, and mapped the implementation surface. The feature is a well-scoped addition to an existing, well-tested authorization system with clear patterns to follow.
- Missing evidence: None
Test Considerations
- Unit tests in
workspace_authz.rs: Add tests for subject-based admin grant, subject-based admin with workspace membership bypass, subject-based admin + role-based admin coexistence, and empty admin_subjects (no effect).
- Unit tests in
authz.rs: Add tests for admin_subjects granting access to platform-admin-scoped methods.
- Config serialization tests: Ensure
admin_subjects round-trips through TOML serialization/deserialization.
- CLI integration tests: Verify
OPENSHELL_OIDC_ADMIN_SUBJECTS env var and --oidc-admin-subjects CLI arg are parsed correctly.
- Helm template tests: Extend
deploy/helm/openshell/tests/gateway_config_test.yaml to verify admin_subjects rendering.
- E2e tests (optional): The existing
e2e/rust/tests/oidc_pkce.rs tests could be extended with a Keycloak user whose subject (not role) is configured as admin_subjects, but this is a stretch goal given the e2e test infrastructure required.
- Existing test patterns: Follow the
platform_admin_bypasses_membership_check and auth_disabled_empty_admin_role_is_platform_admin patterns in workspace_authz.rs. The test module creates user_principal(subject, roles) helpers that can be reused.
Created by spike investigation. Validated means the issue is ready for human disposition. A human applies state:accepted if OpenShell should pursue the work and places it on the roadmap separately. To queue unattended agent planning, a human applies agent:plan-requested; a direct request to an agent does not require that label.
Problem Statement
Today, granting Platform Admin privileges on an OpenShell gateway requires the IdP to assign a specific role (e.g.
openshell-admin) in the JWT claims. There is no way to designate a specific OIDC subject (subclaim) as a Platform Admin directly in the gateway configuration. This creates friction when bootstrapping a new gateway (chicken-and-egg: you need an admin to configure roles, but the admin role must come from the IdP), when the IdP doesn't support custom roles, or when an operator wants gateway-local admin overrides independent of IdP role assignments.A gateway-level
admin_subjectsconfiguration option would grant Platform Admin to specific OIDC subjects regardless of their JWT role claims, working alongside the existing role-based admin check — either mechanism grants admin access.Technical Context
The gateway's authorization system has two layers that enforce admin status:
AuthzPolicy.check()) — evaluates whether an authenticated identity's roles include the configuredadmin_roleto gate access to platform-admin-scoped gRPC methods.workspace_authz::is_platform_admin()) — called directly by gRPC handlers to bypass workspace membership checks for platform admins.Both layers currently check only the identity's JWT roles against the configured
admin_rolestring. The OIDC subject (subclaim) — already available on theIdentitystruct — is never consulted for admin status determination.Affected Components
crates/openshell-core/src/config.rsOidcConfigstruct — holds OIDC settings includingadmin_rolecrates/openshell-server/src/cli.rscrates/openshell-server/src/lib.rsServerState— carriesadmin_role: Stringused by all handlerscrates/openshell-server/src/auth/authz.rsAuthzPolicy.check()— method-level RBAC using rolescrates/openshell-server/src/auth/workspace_authz.rsis_platform_admin(),require_platform_admin(),authorize_workspace()crates/openshell-server/src/multiplex.rsAuthzPolicyfrom OIDC config for the gRPC auth middlewarecrates/openshell-server/src/grpc/{sandbox,workspace,service,provider,policy}.rs&state.admin_roleto authorization functionscrates/openshell-server/src/inference.rs&state.admin_roleto authorization functionsdeploy/helm/openshell/values.yaml,templates/gateway-config.yamlgateway.tomldocs/reference/gateway-config.mdx,docs/reference/gateway-auth.mdxTechnical Investigation
Architecture Overview
Authentication and authorization are separated per RFC 0001. Authentication providers (OIDC, mTLS, etc.) produce an
Identitywith subject, display_name, roles, and scopes. The authorization layer then evaluates this identity without knowing which provider authenticated the caller.The admin check flows through two paths:
Path 1 — Middleware RBAC (
authz.rs):OidcConfig.admin_role→AuthzPolicy.admin_role(constructed inmultiplex.rs:287) →AuthzPolicy.check()comparesidentity.rolesagainstadmin_roleto gate platform-admin-scoped gRPC methods.Path 2 — Handler workspace authorization (
workspace_authz.rs):OidcConfig.admin_role→ServerState.admin_role(set inlib.rs:208) → passed as&state.admin_roletoauthorize_workspace()/require_platform_admin()→ callsis_platform_admin(identity_roles, admin_role)which checksadmin_role.is_empty() || identity_roles.iter().any(|r| r == admin_role).Both paths have access to
Identity.subjectbut never check it for admin determination.Code References
crates/openshell-core/src/config.rs:522OidcConfigstruct —#[serde(deny_unknown_fields)], holdsadmin_role: Stringcrates/openshell-core/src/config.rs:541admin_rolefield withdefault_admin_role()→"openshell-admin"crates/openshell-server/src/lib.rs:176ServerState.admin_role: Stringfieldcrates/openshell-server/src/lib.rs:205-208admin_rolepopulated fromconfig.oidc.admin_roleinServerState::new()crates/openshell-server/src/auth/authz.rs:30AuthzPolicystruct withadmin_role: Stringcrates/openshell-server/src/auth/authz.rs:75-95AuthzPolicy.check()— role-based method authorizationcrates/openshell-server/src/auth/workspace_authz.rs:170-172is_platform_admin()— core admin check:admin_role.is_empty() || identity_roles.iter().any(|r| r == admin_role)crates/openshell-server/src/auth/workspace_authz.rs:149-157require_platform_admin()— called by handlers for cross-workspace opscrates/openshell-server/src/auth/workspace_authz.rs:73-86authorize_workspace()— checksis_platform_admin()to bypass membershipcrates/openshell-server/src/multiplex.rs:287-290AuthzPolicyconstruction fromoidcconfigcrates/openshell-server/src/cli.rs:172-175--oidc-admin-rolearg, envOPENSHELL_OIDC_ADMIN_ROLEcrates/openshell-server/src/cli.rs:659-673admin_roleto CLI argsdeploy/helm/openshell/values.yaml:305oidc.adminRoleHelm valuedeploy/helm/openshell/templates/gateway-config.yaml:103-104admin_roleinto[openshell.gateway.oidc]TOML sectionCurrent Behavior
The core admin determination happens in
workspace_authz.rs:When
admin_roleis empty (OIDC not configured or auth-only mode), every authenticated user is treated as platform admin. Otherwise, the user must have theadmin_rolein their JWTrolesclaim.At the middleware level,
AuthzPolicy.check()similarly comparesidentity.rolesagainstadmin_roleto determine if the caller can access platform-admin-scoped methods. Admin role holders implicitly satisfy user-role requirements.There are ~30+ call sites across
grpc/sandbox.rs,grpc/workspace.rs,grpc/service.rs,grpc/provider.rs,grpc/policy.rs, andinference.rsthat pass&state.admin_roletoauthorize_workspace(),authorize_sandbox_workspace(), orrequire_platform_admin().What Would Need to Change
Config layer (
openshell-core): Addadmin_subjects: Vec<String>toOidcConfig. Thedeny_unknown_fieldsserde attribute means this must be an explicit field.CLI layer (
openshell-server/cli.rs): Add--oidc-admin-subjectsCLI arg withOPENSHELL_OIDC_ADMIN_SUBJECTSenv var. Add config-file overlay logic in the same pattern asadmin_role.Server state (
openshell-server/lib.rs): Addadmin_subjectsfield toServerState, populated fromconfig.oidc.admin_subjectsinServerState::new().Middleware RBAC (
authz.rs): Addadmin_subjects: HashSet<String>toAuthzPolicy. Modifycheck()to also grant admin access whenidentity.subjectis inadmin_subjects.Handler authorization (
workspace_authz.rs): Modifyis_platform_admin()signature to accept the caller's subject and the admin_subjects set. Updaterequire_platform_admin(),authorize_workspace(), andis_platform_admin_principal()accordingly.Call site churn mitigation: The ~30+ call sites that pass
&state.admin_rolewould need updating. A recommended approach: introduce anAdminPolicystruct (or similar) that bundlesadmin_roleandadmin_subjects, then changeServerStateto carryadmin_policy: AdminPolicyand update the authorization function signatures to take&AdminPolicy. This localizes the change and prevents future churn if more admin criteria are added.Multiplex (
multiplex.rs): UpdateAuthzPolicyconstruction to includeadmin_subjects.Helm chart: Add
adminSubjects: []tovalues.yamlunderserver.oidc. Render asadmin_subjects = ["sub1", "sub2"]in the gateway-config.yaml template.Docs: Update
docs/reference/gateway-config.mdxanddocs/reference/gateway-auth.mdxfor the new config field, CLI flag, and env var.Alternative Approaches Considered
Flat approach — add
admin_subjectsas a sibling field toadmin_rolein all signatures. Simpler to implement but results in ~30+ call-site changes and makes future admin-criteria additions equally expensive.AdminPolicy struct — bundle
admin_role+admin_subjectsinto one struct, thread that through instead of separate fields. More upfront work but reduces call-site churn to a type change and prevents future N+1 field threading. Recommended.Synthetic role injection — when the subject matches
admin_subjects, inject theadmin_roleinto the identity's roles list at the authentication layer, before authorization runs. This would require zero changes to the authorization layer but mixes concerns: the auth layer would be modifying identity claims, which could confuse audit logging andGetCurrentUserresponses. Not recommended.Patterns to Follow
admin_rolepattern —#[serde(default)]with a default function, matching CLI arg + env var + config-file overlay.OPENSHELL_OIDC_ADMIN_SUBJECTSwith comma-separated values (consistent with how list-type env vars are handled elsewhere).workspace_authz.rstest module has thorough coverage of admin scenarios. New tests should followplatform_admin_bypasses_membership_checkandauth_disabled_empty_admin_role_is_platform_adminpatterns.authz.rshas role-based tests that should be extended for subject-based admin access.Proposed Approach
Introduce an
AdminPolicystruct that bundles the admin role name and admin subjects set, replacing the flatadmin_role: Stringthreading. Addadmin_subjectstoOidcConfig(config), CLI args (with env varOPENSHELL_OIDC_ADMIN_SUBJECTS), and the Helm chart. Modifyis_platform_admin()andAuthzPolicy.check()to grant admin access when the caller's OIDC subject is in the configuredadmin_subjectsset, alongside the existing role-based check. Update gateway config and auth documentation.Scope Assessment
featRisks & Open Questions
admin_subjectsrather than IdP roles, theGetCurrentUserresponse won't show an admin role inroles[]. Operators may want to distinguish how admin access was granted. Consider whether the auth grant path should be surfaced (e.g. in logs or response metadata).admin_roleanduser_roleare empty (auth-only mode), everyone is already admin.admin_subjectsshould be a no-op in that mode — document this.OPENSHELL_OIDC_ADMIN_SUBJECTS=sub1,sub2or multi-value--oidc-admin-subject sub1 --oidc-admin-subject sub2? Clap supports both; comma-separated env var + multi-value CLI flag is the most ergonomic.HashSet<String>is appropriate for O(1) lookup. The current role check is O(n) on roles which is fine for small role lists.Disposition Readiness
Test Considerations
workspace_authz.rs: Add tests for subject-based admin grant, subject-based admin with workspace membership bypass, subject-based admin + role-based admin coexistence, and empty admin_subjects (no effect).authz.rs: Add tests for admin_subjects granting access to platform-admin-scoped methods.admin_subjectsround-trips through TOML serialization/deserialization.OPENSHELL_OIDC_ADMIN_SUBJECTSenv var and--oidc-admin-subjectsCLI arg are parsed correctly.deploy/helm/openshell/tests/gateway_config_test.yamlto verifyadmin_subjectsrendering.e2e/rust/tests/oidc_pkce.rstests could be extended with a Keycloak user whose subject (not role) is configured as admin_subjects, but this is a stretch goal given the e2e test infrastructure required.platform_admin_bypasses_membership_checkandauth_disabled_empty_admin_role_is_platform_adminpatterns inworkspace_authz.rs. The test module createsuser_principal(subject, roles)helpers that can be reused.Created by spike investigation. Validated means the issue is ready for human disposition. A human applies
state:acceptedif OpenShell should pursue the work and places it on the roadmap separately. To queue unattended agent planning, a human appliesagent:plan-requested; a direct request to an agent does not require that label.