Skip to content
SHC Docs

Access control

SHC enforces tenant isolation and per-stack permissions through a single Keycloak instance, one realm per tenant, and a role grammar evaluated at the ingress edge plus the API.

PieceRole
System KeycloakSingle Keycloak deployment in the system tenant; the IdP for the entire SHC instance. Has no default main realm — realms are per-tenant.
Tenant realmOne realm per SHC tenant, named after the tenant (e.g. acme). Users belong to exactly one realm.
shc-access clientA bearer-only client provisioned in every realm. Holds the tenant:* and stack:* roles that drive permission decisions.
Forward-auth endpoint/api/auth/forward-auth — Traefik calls it for every request to a gated host; SHC answers allow/deny based on the user’s roles + the host→stack mapping.
Access axisPer-deployment: access: gated (behind SHC SSO via Traefik forward-auth) or access: open (no gate — the app relies on its own auth). Set at install with the mutually-exclusive --gated / --open flags. open is the default.
Exposure axisPer-deployment and orthogonal to access: exposure: lan (the default — reachable on the LAN only) or exposure: internet. Set at install with --public, which raises exposure to internet. --public controls exposure, not access — it does not skip the SSO gate.

Access and exposure are two independent axes. Whether a deployment is gated (SSO in front) is decided by --gated/--open; whether it is reachable from the internet vs LAN-only is decided by --public. An app can be open + internet, gated + lan, or any combination.

The legacy per-app public: field in app.yaml is a read-only migration seed: on read, NormalizeDeploymentDisposition (modules/app/disposition.go) maps a set public: trueaccess: open and absent/falseaccess: gated, with exposure defaulting to lan. An already-set access/exposure always wins. There is no longer a --no-public flag — to gate an app use --gated.

External L4 load balancers are orthogonal to both axes. There is no --proxy flag and no proxy.connection config: LB attachment lives inside the port-exposing flag — --port <conn>@<external>[:service][:container][/udp] rides the named proxy-capable connection’s load balancer (pfSense HAProxy / AWS NLB / GCP network LB), for any app including the system traefik itself (proxy the ingress once — --port mylb@80:traefik:80 --port mylb@443:traefik:443 — and every --host DNS record follows its VIP automatically). Per-app LB mappings remain for raw-port apps and deliberate own-LB overrides. The gate still runs at Traefik’s forward-auth regardless of which LB fronts the traffic.

interactive — drag/click to explore
  • shc tenant create acme calls provision_tenant_realm("acme"). If the system Keycloak is up, the realm + shc-access client are created via kcadm in the system Keycloak container. If Keycloak isn’t reachable yet, the call no-ops with a debug log — tenants still create cleanly, realms are reconciled when Keycloak comes online.
  • shc tenant rm acme calls deprovision_tenant_realm("acme") which drops the realm.
  • Reserved tenant names (from core/validation/reserved_names.go, case-insensitive): platform, tenant, stack, environment, use, read, operate, manage, admin, tenants, master, system, cluster, global-root, logs, metrics, traces, events, audit. Creating any of these fails the validation.TenantName check, which raises X400982NAMINV — wrapped by tenant create as X400974TNTNAM.
  • On install: _provision_stack_access_role creates stack:<name> on the tenant’s shc-access client. Always provisioned regardless of the deployment’s access axis — the role is used for admin grant tracking even when the stack is open (no forward-auth gate at the Traefik layer). Skipped only for vapp instances (which share their parent’s role).
  • On uninstall: the symmetric delete_access_role runs after post_uninstall.
<scope>[:<verb>]
  • Bare scope → implicit use verb.
  • Verb hierarchy (descending): manageoperatereaduse.
  • Holding a higher verb implicitly satisfies any lower-verb check.

Scopes and what they grant:

ScopeMeaning
tenantMember of the tenant.
tenant:manageTenant admin (also operate/read/use).
env:<env>Membership in a specific environment.
stack:<name>Use rights on a specific stack.
stack:<name>:operateOperate (start/stop/restart) on a stack.
stack:<name>:manageFull lifecycle control.
platform:manageSHC-platform admin (system realm only). Implicitly satisfies every other check on every tenant.

Cross-class implication is only through platform:manage. Holding tenant:manage does not automatically grant stack:<name>:manage unless the user also has a per-stack role — but it does grant blanket access in user-facing list endpoints (see _accessible_stacks_for).

There is no user-facing syntax for cross-tenant integration. The tenant/stack form is not recognized; attempts fall through to the normal same-tenant resolver and fail to find the stack.

Cross-tenant access exists only via the SHC-controlled “magic tokens” registered in the magicProviders allowlist in modules/app/integration_magic.go. Each magic token is an all-caps string that resolves to a specific stack in the master (system) tenant, with one plug-data field overridden to the consumer tenant’s name so each tenant lands in its own logical compartment within the shared system instance. Parsing stamps IntegrationSpec.ProviderTenant = master for these tokens; resolveIntegrations and validateIntegrationProviders are the only places that honour a non-empty ProviderTenant, so the tenant boundary stays closed for every other integration.

Currently registered tokens:

TokenSystem stackSocketPlug-data overridePurpose
KEYCLOAKkeycloakkeycloakrealm=<consumer_tenant>Shared IdP — every tenant gets its own realm under a single Keycloak

Both forms work for any registered token:

Terminal window
# Form 1 — auto-match the consumer's matching plug
shc install nextcloud --integrate KEYCLOAK
# Form 2 — explicit plug-name binding (required when the consumer has
# more than one plug targeting that socket type)
shc install matrix --integrate sso=KEYCLOAK

There is deliberately no observability token. Telemetry is fabric-only: an app never wires itself to the observability store, it pushes to SHC’s own collector, which stamps scope, scrubs, filters and routes. An OPENOBSERVE token and an app-facing openobserve socket both existed and were removed — see telemetry-fabric.md.

Magic tokens cannot be qualified with @env (the provider always lives in the master tenant’s default environment) — validateIntegrationProviders rejects this combination with X400903INVAPP rather than failing later as “stack not found”. There is no socket-qualified form 3; the provider socket is predetermined.

Adding another magic token: append a magicProvider{...} to the magicProviders allowlist in integration_magic.go, install the corresponding stack into the master tenant, and document it here. The resolver and validator are generic over the table — no per-token branches needed.

Not every shared service is safe as a magic token. The registered token is a control-plane service that compartmentalises cleanly on a single shared field (Keycloak by realm). A shared datastore (postgres/mysql) is deliberately not registered: its integration mints a per-consumer role + database whose names come from the consumer’s plug (gitea, nextcloud, …) with no tenant component, so two tenants installing the same app against one shared instance would collide on the same cluster-global role/database name. The provisioning job’s create-if-absent + unconditional ALTER … OWNER/ALTER USER … PASSWORD turns that collision into silent cross-tenant takeover, and either tenant’s uninstall drops the shared database. Compartmentalising a datastore needs per-tenant namespacing of both the role and the database (e.g. <tenant>_<app>), which the single-field OverrideField cannot express — so datastores stay per-tenant until that exists.

A provider socket’s data: block is rendered against the provider’s vars, so for a magic (cross-tenant) provider it holds the master instance’s real credentials (e.g. the Keycloak admin password). Those must never land in a foreign tenant’s rendered compose. On a boundary-crossing edge (ProviderTenant set and ≠ the consumer’s tenant), resolveIntegrations blanks every socket field the socket schema marks @secret in the consumer-facing copy (ConsumerSocketData); the full SocketData is kept only for the provider-side integration jobs, which run inside the master instance and legitimately need the admin credential. Same-tenant edges keep the secrets (one trust domain). The redaction is schema-driven, so it covers every @secret field automatically:

TokenRedacted socket fields (cross-tenant only)
KEYCLOAKsso.socket.yamladminPassword

A cross-tenant consumer authenticates with its own scoped credential — the clientSecret / per-tenant role / etc. it supplied through its plug, provisioned by the provider-side job — never the provider’s admin secret. (Virtual sockets, *.vsocket.yaml, are never bindable cross-tenant — findSocketForPlug / inferPlugFromProvider match only *.socket.yaml — so their secrets are safe by construction.)

Cross-environment access (within a tenant)

Section titled “Cross-environment access (within a tenant)”

Same tenant, different environment is permitted via the @env suffix on the provider in any non-magic-token integration spec. Useful when infrastructure-shaped apps (mail, s3, keycloak, etc.) live in a shared environment like main or prod and other environments within the same tenant want to consume them rather than running their own copy:

Terminal window
# `dev` environment's nextcloud uses `prod` environment's mail relay
shc -e dev install nextcloud --integrate mail@prod
# Explicit plug + cross-env
shc -e dev install nextcloud --integrate smtp=mail@prod
# Socket-qualified + cross-env
shc -e dev install nextcloud --integrate smtp=mail.smtp@prod

The consumer’s own stack is created in its requested environment (-e dev); only the provider lookup runs in the override env. The tenant-isolation rule still applies — provider@otherenv only resolves within the same tenant. Cross-tenant routes still require a magic token.

  1. Browser hits nextcloud.example.com.
  2. Traefik invokes forwardAuth middleware → GET /api/auth/forward-auth with X-Forwarded-Host: nextcloud.example.com and the client’s Authorization header (or shc_access_token cookie).
  3. SHC validates the bearer token via the issuing realm’s userinfo endpoint (the realm comes from the JWT’s iss claim, not from any tenants payload claim).
  4. SHC looks up the host in host_mapping → tenant + stack.
  5. Decision tree:
    • Platform admin → allow.
    • User’s tenant != stack’s tenant → 403 (cross_tenant).
    • User has stack:<name>:use (or higher) → allow.
    • Otherwise → 403 (missing_role).
  6. Allow responses set X-SHC-User, X-SHC-Tenant, X-SHC-Stack for downstream consumption.

The public chi router exposes interactive Swagger UI at /docs. When keycloak.url is set, Swagger UI shows an Authorize button that runs the OAuth2 authorization-code + PKCE flow against the system Keycloak — no token copy-pasting required.

provision_tenant_realm (called on every shc tenant create) creates a public OIDC client called shc-swagger in the new realm alongside the bearer-only shc-access client. The client has:

  • publicClient: true, standardFlowEnabled: true, PKCE (pkce.code.challenge.method=S256).
  • redirectUris: ["*"], webOrigins: ["*"] so the same client works across every node URL the operator might serve /docs from. Tighten to specific origins from the Keycloak admin console for production-grade hardening.
  • An oidc-audience-mapper adding shc-access to the access token so validate_keycloak_token reads the same resource_access["shc-access"].roles claim it expects from CLI-issued tokens.
KeyDefaultMeaning
keycloak.urlnullKeycloak base URL. SSO is disabled when null.
keycloak.swagger_client_idshc-swaggerClient ID Swagger UI uses for the OAuth2 flow.
keycloak.swagger_realmnull (→ system tenant)Which realm /docs logs into.
  1. Operator opens /docs. The custom route in core/api/server.go renders Swagger UI with initOAuth containing clientId, scopes, and usePkceWithAuthorizationCodeGrant: true.
  2. _custom_openapi injects a KeycloakOIDC security scheme (and keeps the existing HTTPBearer for raw token paste) on every operation that already required auth.
  3. Operator clicks Authorize → Swagger UI redirects to <keycloak.url>/realms/<swagger_realm>/protocol/openid-connect/auth with PKCE parameters.
  4. Keycloak login → redirect to /docs/oauth2-redirect → Swagger UI exchanges the code at the token endpoint and stores the access token in-memory.
  5. Every subsequent Try it out call sends Authorization: Bearer <token> to the SHC API. The token is validated via the issuing realm’s userinfo endpoint exactly like CLI / forward-auth tokens.

Swagger UI is bound to a single OIDC issuer per page load, so users whose realm differs from keycloak.swagger_realm should:

  • Switch to the HTTPBearer option in the Authorize dialog and paste a JWT issued by their own realm (e.g. via shc auth login), or
  • Temporarily set keycloak.swagger_realm to their tenant.

The runtime token validator (validate_keycloak_token) routes by the iss claim, so a tenant-realm token is accepted regardless of which realm Swagger UI’s button is wired to.

  • GET /api/user/apps — every stack the user can use (plus app/host metadata). Platform admins see every tenant’s stacks.
  • GET /api/user/managed-apps — stacks where the user holds at least manage (per-stack, tenant-wide, or platform-wide).

A small <script> tag is injected into the </body> of every gated app’s HTML responses. It renders a fixed-position “Apps” button (currently a stub — clicks do nothing yet; the eventual behavior is a popup app-list backed by /api/user/apps).

Body rewrite happens in Traefik itself via the public rewritebody plugin (Yaegi-based). No extra container, no extra hop, no proxy replacement — Traefik does the rewrite in-process.

Per gated stack with the button enabled, SHC declares the middleware in the Traefik file-provider dynamic config — the same route file SHC writes for the gated router (writeIngressRouteFile), NOT docker labels: (the file provider only reads docker labels on Traefik’s own node, so a per-project label wouldn’t reach cross-node routers). The emitted http.middlewares.shc-launcher-<project> block is:

http:
middlewares:
shc-launcher-<project>:
plugin:
rewritebody:
lastModified: true
rewrites:
- regex: "</body>"
replacement: "<script>...</script></body>"
# ...and the gated router's middleware chain points at this middleware after forwardauth.

The middleware name is namespaced by docker project so multiple gated stacks on the same Traefik don’t share state.

The single helper that emits this is RewritebodyMiddleware() in modules/auth/launcher.go (the name is produced by LauncherMiddlewareName(project)).

The plugin must be declared in Traefik’s static config so it gets loaded at startup. Add to the traefik command block:

- --experimental.plugins.rewritebody.modulename=github.com/traefik/plugin-rewritebody
- --experimental.plugins.rewritebody.version=v0.3.1

(Pin the version explicitly; the plugin is fetched at Traefik startup.)

Two sources resolved as a scoped override ladder (there is no deployment.launcher column and no state.json field — this is pure config):

SourceWhere
Scoped configthe launcher config key, resolved over the deployment → stack → environment → tenant → global cascade. Set at any scope, it wins outright (shc config set launcher false is the kill-switch).
App defaultapp.yaml field launcher: (true when absent), used only when the config key is unset at every scope.

If the scoped launcher config key is set anywhere it wins outright — so a config true can turn an app back on even if its author defaulted launcher: false, and vice versa. There is no “off-only” restriction.

modules/auth/launcher.go:EffectiveLauncher(appDefault bool, configValue *bool) bool is the single source of truth: if configValue != nil return it, else appDefault.

What we deliberately don’t support (yet)

Section titled “What we deliberately don’t support (yet)”
  • Per-host path-scoped roles (stack:nc:read /admin). The scope grammar reserves room for this but the resolver doesn’t read paths today.
  • Bring-your-own-Keycloak. v1 federation is via Keycloak brokering inside the system realm. A separate-Keycloak-per-tenant model is a bigger lift and adds operational surface; we kept the single-instance design until somebody needs more.
  • Self-service role assignment UI. Roles are managed via kcadm or the Keycloak admin console; an SHC-side abstraction can come later.