Description
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 configuredadmin_roleto 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_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
- Config fields: Follow the
admin_rolepattern —#[serde(default)]with a default function, matching CLI arg + env var + config-file overlay. - CLI env vars: Use
OPENSHELL_OIDC_ADMIN_SUBJECTSwith 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.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 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_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). - Auth-only mode interaction: When both
admin_roleanduser_roleare empty (auth-only mode), everyone is already admin.admin_subjectsshould be a no-op in that mode — document this. - CLI arg format: Comma-separated
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. - 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_subjectsround-trips through TOML serialization/deserialization. - CLI integration tests: Verify
OPENSHELL_OIDC_ADMIN_SUBJECTSenv var and--oidc-admin-subjectsCLI arg are parsed correctly. - Helm template tests: Extend
deploy/helm/openshell/tests/gateway_config_test.yamlto verifyadmin_subjectsrendering. - E2e tests (optional): The existing
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. - Existing test patterns: Follow the
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: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.
Activity