Skip to content

feat: allow granting Platform Admin by OIDC subject in gateway config #2613

Description

@jhjaggars

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:

  1. 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.
  2. 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_roleAuthzPolicy.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_roleServerState.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

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions