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.
The pieces
Section titled “The pieces”| Piece | Role |
|---|---|
| System Keycloak | Single Keycloak deployment in the system tenant; the IdP for the entire SHC instance. Has no default main realm — realms are per-tenant. |
| Tenant realm | One realm per SHC tenant, named after the tenant (e.g. acme). Users belong to exactly one realm. |
shc-access client | A 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 axis | Per-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 axis | Per-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 beopen + internet,gated + lan, or any combination.The legacy per-app
public:field inapp.yamlis a read-only migration seed: on read,NormalizeDeploymentDisposition(modules/app/disposition.go) maps a setpublic: true→access: openand absent/false→access: gated, withexposuredefaulting tolan. An already-setaccess/exposurealways wins. There is no longer a--no-publicflag — to gate an app use--gated.External L4 load balancers are orthogonal to both axes. There is no
--proxyflag and noproxy.connectionconfig: 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--hostDNS 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.
Tenant lifecycle
Section titled “Tenant lifecycle”shc tenant create acmecallsprovision_tenant_realm("acme"). If the system Keycloak is up, the realm +shc-accessclient 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 acmecallsdeprovision_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 thevalidation.TenantNamecheck, which raisesX400982NAMINV— wrapped by tenant create asX400974TNTNAM.
Stack lifecycle
Section titled “Stack lifecycle”- On install:
_provision_stack_access_rolecreatesstack:<name>on the tenant’sshc-accessclient. Always provisioned regardless of the deployment’saccessaxis — the role is used for admin grant tracking even when the stack isopen(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_roleruns afterpost_uninstall.
Role grammar
Section titled “Role grammar”<scope>[:<verb>]- Bare scope → implicit
useverb. - Verb hierarchy (descending):
manage⊃operate⊃read⊃use. - Holding a higher verb implicitly satisfies any lower-verb check.
Scopes and what they grant:
| Scope | Meaning |
|---|---|
tenant | Member of the tenant. |
tenant:manage | Tenant admin (also operate/read/use). |
env:<env> | Membership in a specific environment. |
stack:<name> | Use rights on a specific stack. |
stack:<name>:operate | Operate (start/stop/restart) on a stack. |
stack:<name>:manage | Full lifecycle control. |
platform:manage | SHC-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).
Cross-tenant access
Section titled “Cross-tenant access”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:
| Token | System stack | Socket | Plug-data override | Purpose |
|---|---|---|---|---|
KEYCLOAK | keycloak | keycloak | realm=<consumer_tenant> | Shared IdP — every tenant gets its own realm under a single Keycloak |
Both forms work for any registered token:
# Form 1 — auto-match the consumer's matching plugshc 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=KEYCLOAKThere 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.
Secrets never cross the boundary
Section titled “Secrets never cross the boundary”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:
| Token | Redacted socket fields (cross-tenant only) |
|---|---|
KEYCLOAK | sso.socket.yaml → adminPassword |
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:
# `dev` environment's nextcloud uses `prod` environment's mail relayshc -e dev install nextcloud --integrate mail@prod
# Explicit plug + cross-envshc -e dev install nextcloud --integrate smtp=mail@prod
# Socket-qualified + cross-envshc -e dev install nextcloud --integrate smtp=mail.smtp@prodThe 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.
Forward-auth flow
Section titled “Forward-auth flow”- Browser hits
nextcloud.example.com. - Traefik invokes
forwardAuthmiddleware →GET /api/auth/forward-authwithX-Forwarded-Host: nextcloud.example.comand the client’sAuthorizationheader (orshc_access_tokencookie). - SHC validates the bearer token via the issuing realm’s userinfo
endpoint (the realm comes from the JWT’s
issclaim, not from anytenantspayload claim). - SHC looks up the host in
host_mapping→ tenant + stack. - 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).
- Allow responses set
X-SHC-User,X-SHC-Tenant,X-SHC-Stackfor downstream consumption.
Swagger UI SSO (/docs)
Section titled “Swagger UI SSO (/docs)”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.
Provisioning
Section titled “Provisioning”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/docsfrom. Tighten to specific origins from the Keycloak admin console for production-grade hardening.- An
oidc-audience-mapperaddingshc-accessto the access token sovalidate_keycloak_tokenreads the sameresource_access["shc-access"].rolesclaim it expects from CLI-issued tokens.
Configuration
Section titled “Configuration”| Key | Default | Meaning |
|---|---|---|
keycloak.url | null | Keycloak base URL. SSO is disabled when null. |
keycloak.swagger_client_id | shc-swagger | Client ID Swagger UI uses for the OAuth2 flow. |
keycloak.swagger_realm | null (→ system tenant) | Which realm /docs logs into. |
Request flow
Section titled “Request flow”- Operator opens
/docs. The custom route incore/api/server.gorenders Swagger UI withinitOAuthcontainingclientId,scopes, andusePkceWithAuthorizationCodeGrant: true. _custom_openapiinjects aKeycloakOIDCsecurity scheme (and keeps the existingHTTPBearerfor raw token paste) on every operation that already required auth.- Operator clicks Authorize → Swagger UI redirects to
<keycloak.url>/realms/<swagger_realm>/protocol/openid-connect/authwith PKCE parameters. - Keycloak login → redirect to
/docs/oauth2-redirect→ Swagger UI exchanges the code at the token endpoint and stores the access token in-memory. - 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.
Cross-realm callers
Section titled “Cross-realm callers”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_realmto 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.
User self-introspection
Section titled “User self-introspection”GET /api/user/apps— every stack the user canuse(plus app/host metadata). Platform admins see every tenant’s stacks.GET /api/user/managed-apps— stacks where the user holds at leastmanage(per-stack, tenant-wide, or platform-wide).
Floating launcher button
Section titled “Floating launcher button”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).
Injection mechanism
Section titled “Injection mechanism”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)).
Operator step (one-time per deployment)
Section titled “Operator step (one-time per deployment)”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.)
Per-stack and global enable/disable
Section titled “Per-stack and global enable/disable”Two sources resolved as a scoped override ladder (there is no
deployment.launcher column and no state.json field — this is pure
config):
| Source | Where |
|---|---|
| Scoped config | the 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 default | app.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.