Security model
How SHC authenticates callers, how it authorizes actions against tenants
and resources, how tenants are isolated from each other, and how
cluster-internal traffic is secured. This page is the canonical
reference; the core/api/auth.go,
modules/auth/types.go, and
modules/auth/middleware.go modules are the source
of truth.
1. Authentication
Section titled “1. Authentication”Every request resolves a caller through Service.CurrentUser in
modules/auth/service.go,
using the auth precedence extracted by ExtractAuth in
core/api/auth.go.
There are exactly two transports that can authenticate a caller:
1.1 Unix Domain Socket (local admin)
Section titled “1.1 Unix Domain Socket (local admin)”Requests arriving over the UDS listener (default
/run/shc/shc.sock) are treated as local admin requests and minted by
NewLocalUser (modules/auth/types.go)
as a synthetic *User with roles ["platform:manage"] and a wildcard
permission {Resource: "*", Scopes: ["*"]} (wildcard tenant access
comes from IsPlatformAdmin() / CanAccessTenant, not a tenants
set). Access to
the socket file is gated by filesystem permissions — the socket is
owned by the shc user, and its group is shc; members of that group
(set with usermod -aG shc <user>) can open it. Everything else
cannot.
The detection lives in the udsMarkerMiddleware that tags the request
with Transport=uds at ingress; CurrentUser mints the local admin
when that marker is present.
Authorization bypass (see §2.4) keys off the unexported isLocal
bool flag on *User (set only by NewLocalUser), not a claim string
or a Go type assertion — so a
Keycloak user whose sub happens to equal "local-user" cannot
escalate.
1.2 Keycloak (everyone else)
Section titled “1.2 Keycloak (everyone else)”Every other transport on the public listener — HTTP(S) and
WebSockets — must present a Bearer token
that validate_keycloak_token(token) accepts. The token is validated
against the Keycloak realm configured in authentication.keycloak.*
(see config keys). On
success, the returned User includes the sub, roles, tenant
claims, and resolved permissions (§2.2).
Failures raise X401900NOAUTH (no credentials) or X401901* (token
invalid). Failed authentications emit A401000AUTHFL audit events
with a reason tag.
1.3 Public client API: TLS
Section titled “1.3 Public client API: TLS”The Keycloak-protected, tenant REST API (what clients and the CLI use
over the network) listens plaintext on 0.0.0.0:8000 (overridable with
server.tcp.bind, or SHC_PORT for per-daemon test isolation; the public
host is SHC_HOST) (the
http listener subsystem in subsystems/impl/http.go).
TLS is not terminated inside SHC — traefik (installed as a system
app) fronts this listener and handles TLS, Let’s Encrypt, and routing
by host+path. ACME resolvers are injected by the daemon’s traefik seed
(see apps/traefik/compose.yaml), not by an app-level plugin.
2. Authorization
Section titled “2. Authorization”Once a caller is authenticated, every endpoint that changes state (and
most that read it) is wrapped with RequirePermissions(...) from
modules/auth/middleware.go. The middleware runs
four checks in order.
2.1 Context present
Section titled “2.1 Context present”RequirePermissions pulls the Ctx object from the request context. If no Ctx is available the
request is rejected with X403000AUTHZF — this is a programming error,
not an auth failure, and indicates a route that forgot to take a
ctx: Ctx = Depends(get_ctx) parameter.
2.2 User present
Section titled “2.2 User present”Non-UDS requests must carry a resolved user. Missing user ⇒
X403903NOUSER.
2.3 Tenant membership (IDOR guard)
Section titled “2.3 Tenant membership (IDOR guard)”Before any scope check, ctx.user.can_access_tenant(ctx.tenant_name)
must return True. The tenant on the Ctx was derived from an
untrusted source — the X-SHC-Tenant HTTP header, or the
--tenant CLI flag — so this check is mandatory. Without it, a user
with apps:write on tenant A could set the header to tenant B and
write apps in B.
CanAccessTenant (modules/auth/types.go)
considers three cases (the User model carries a single
TenantName string field — there is no multi-valued tenants set):
- Empty tenant (
tenantName == "", cluster-wide probe) → allow; the per-scope check is the real gate. - Platform admin (
IsPlatformAdmin()— holdsplatform:manage, or is the syntheticLocalUser) → allow. user.TenantName == requested→ allow. This single-tenant equality is the primary match for Keycloak users.
Denial raises X403904NOTENT and emits an A403002TENTDNY audit
event with the attempted tenant and the user’s sub.
2.4 Scope & resource check
Section titled “2.4 Scope & resource check”The required scopes are the arguments to @permissions(...) — e.g.
@permissions("stack:write"). The current resource is derived from
the Ctx by _build_resource:
- If the endpoint declared
resource_type="tenant", or theCtxhas no stack and no environment → resource is"tenant". - If the
Ctxhas a stack → resource is"stack:<stack_name>". - If it only has an environment → resource is
"environment:<name>". - Otherwise →
"tenant".
HasPermission(userPermissions, resource, requiredScopes) then
walks the user’s Permission objects:
// pseudocodefor _, perm := range user.Permissions { if matchResource(perm.Resource, currentResource) { for _, userScope := range perm.Scopes { for _, required := range requiredScopes { if matchScope(userScope, required) { return allow } } } }}return denyDenial raises X403900NOPERM(scope=…, resource=…) and emits an
A403001PERMDNY audit event.
LocalUser instances short-circuit this entire pipeline — that’s
the bypass from §1.1.
2.5 Scope syntax
Section titled “2.5 Scope syntax”Scopes are "<noun>:<verb>":
match_scope(user, required)allows:"*"→ matches everything.- Exact match (
stack:write=stack:write). - Noun wildcard: user has
*:read, required isstack:read→ allow. - Verb wildcard: user has
stack:*, required isstack:delete→ allow.
match_resource(user, required) uses fnmatch globs, so a policy
with resource="stack:prod-*" will match stack:prod-web but not
stack:dev-web.
2.6 Canonical scope vocabulary
Section titled “2.6 Canonical scope vocabulary”Most modules gate access via CanAccessTenant + a request-scoped
tenant check rather than a per-noun @permissions vocabulary. The
explicit RequirePermissions(...) middleware guards only a handful of
routers with these scopes.
Source: rg "RequirePermissions\(" over the ce module and this repo’s trees (non-test call sites).
| Scope | Used by |
|---|---|
platform:manage | dns, cluster, storage, nebula, tenant, task, backup, auth, node, config routers (admin gate) |
audit:read | audit |
events:read | events |
stack:read | logs |
3. Policies, roles, and caching
Section titled “3. Policies, roles, and caching”Users don’t carry permissions directly in their token; they carry
roles. The server translates roles → permissions by evaluating
Policy objects (typically sourced from Keycloak custom policies;
see Policy.from_keycloak). Each Policy carries:
nameroles— if the user has any of these, the policy matches.resources— which resource patterns (stack:*,stack:prod-web,tenant, …) the policy grants access to.scopes— which scope strings (stack:read,backup:write, …) are granted on those resources.
get_permissions_for_roles(policies, roles) flattens matching
policies into the Permission list that the user object carries into
RequirePermissions.
Policy evaluation is expensive (Keycloak round-trip for policy
lookup), so results are cached per-client in PolicyCache with a TTL
taken from keycloak.policy_cache_ttl (default 60 seconds). The
cache is an in-process singleton — if a policy changes in Keycloak, it
takes up to the TTL to propagate. Call PolicyCache.invalidate() to
force a refresh.
4. Tenant isolation
Section titled “4. Tenant isolation”Tenants are the top-level namespace in SHC. Every stack, environment, schedule, backup, audit row, and vault secret is rooted at a tenant. Isolation is enforced at four layers:
- Authorization (above): the IDOR guard in
@permissionsblocks cross-tenant requests before the handler runs. - Database: every table that carries user data has a
tenant_namecolumn; every query includes it in theWHEREclause (see thequeries.gofiles in each module). - Filesystem: per-tenant directories under
paths.TenantsDir(tenant)(see core/paths/paths.go). Compose project names (viabuild_docker_project_name(tenant, stack, env)) carry the tenant so container names don’t collide. - Docker networks: the
networkmodule creates per-tenant overlay networks (ensure_tenant_network) so stacks in the same tenant can reach each other while stacks in other tenants cannot.
What is not isolated: host kernel resources, the control plane process, and the underlying docker daemon. SHC is a multi-tenant-within-one-trust-boundary system, not a hostile- multi-tenant system. Operators should not run untrusted-code tenants on the same node.
5. Cluster-internal traffic
Section titled “5. Cluster-internal traffic”Cluster-internal traffic falls into two kinds:
5.1 Node-to-node RPC (internal API)
Section titled “5.1 Node-to-node RPC (internal API)”The internal API (/internal/*, mounted by buildRouter in
modules/cluster/router.go) is used for leader →
follower operations, cross-node shc stack exec, log streaming, etc.
It is authenticated with mTLS: each node presents a client
certificate signed by the cluster CA
(modules/cluster/cert_provider.go).
Certificates are minted at cluster-join time using a short-lived join
token (§5.3) and rotated on a schedule
(subsystems/cluster_cert_rotation.go).
The internal API is not exposed to end users. It runs on a single
listener (TCP 4003) and, by default, binds to all interfaces
(0.0.0.0) so joining nodes can reach it before the mesh is
established. Exactly one port, always TLS.
The listener is configured with ssl_cert_reqs=CERT_OPTIONAL and
ssl_ca_certs=<cluster CA>. This is a deliberate two-layer design,
not a weakened mTLS mode:
- TLS layer. OpenSSL chain-verifies any client cert the peer
presents against the cluster CA during the handshake. A cert
that fails verification aborts the handshake — it never reaches
the application. A peer that presents no cert at all is allowed through
the handshake (so a joining node with no cert yet can reach
/internal/enroll) but that is the only thingCERT_OPTIONALallows past TLS. - Application layer. Every route except the enrollment
allowlist depends on
VerifyInternalAuth(core/api/auth.go), which reads the validated peer cert off the TLS connection state and rejects TLS connections that don’t carry one. A cryptographic “cluster membership” gate, just done one layer up from the kernel socket so the single listener can also serve/enroll— the exact endpoint a joiner-with-no-cert needs.
Net effect on the wire: every non-enrollment route is equivalent
to CERT_REQUIRED, and every byte on the socket is TLS-encrypted,
including the enrollment exchange itself. A plaintext HTTP request
to port 4003 is physically impossible; the server only speaks TLS.
Still:
- Production deployments MUST firewall TCP 4003 from the public
internet. Block it at your host / cloud firewall, leaving only the
mesh subnet (defaults to
10.42.0.0/16) and, for new nodes during join, the specific source IP(s) that will runshc node join. - Operators who want a stricter physical-network boundary bind the
internal API to a specific NIC with
shc init --nic <iface>/shc node join --nic <iface>(the same pin drives every cluster-internal address), or set theinternal_api.bindconfig key (SHC_INTERNAL_API_BIND) for an explicit address. See configuration files reference. - The enrollment endpoint (
/internal/enroll) is explicitly allowlisted not to require a cluster-CA client cert (the joiner doesn’t have one yet) but it does still run over TLS. The cluster-CA public certificate ships embedded in the single-use, TTL-boundshc node tokenjoin token, so the joining node pins the leader’s server cert against the cluster CA during/internal/enroll— closing the MITM window that would otherwise exist when the joiner has no prior trust anchor. Authentication of the call itself is the bootstrap token (§5.3); firewalling 4003 closes even that narrow window.
5.2 Raft consensus (rqlite)
Section titled “5.2 Raft consensus (rqlite)”Durable cluster state (tenants, stacks, schedules, quotas, audit
log, …) lives in rqlite — raft-replicated
SQLite, run as an external rqlited process the daemon supervises.
Raft replication runs on :4002 between members; members join as
voters by default, and a non-voter member replicates read-only.
Transport security (#58 raft mTLS). The raft plane is gated by the
RaftTLS posture recorded in node_bootstrap.json:
- Genesis default-on — a from-empty
shc initalways writesRaftTLS: true(modules/cluster/service.go), so every fresh cluster runs raft replication (:4002) and the rqlited HTTP API (:4001) under mutual TLS:rqlitedgets-node-cert/-node-key/-node-ca-cert+-node-verify-clientand-http-cert/-http-key/-http-ca-cert(core/rqlited/manager.go), using the per-node cluster certs under{state}/certs/(ca.pem,node.pem,node-key.pem). - Leader-inherited — a joiner never decides its own posture; it
adopts the leader’s
RaftTLSfrom the enroll response (modules/cluster/enroll_handler.go), so a cluster is always uniformly TLS or uniformly plaintext. - Legacy plaintext — a
node_bootstrap.jsonwritten before the field existed decodesRaftTLSas false, and that cluster stays plaintext through binary upgrades; flipping it to TLS is a deliberate all-nodes operation, not an upgrade side effect. For those clusters, firewalling :4002 between members remains the only protection. - File-gated bring-up — if the posture says TLS but the cert
material is missing, genesis falls back with a logged warning
(
X503200RFTTLW) while a joiner refuses to start (X500211RFTTLF) rather than silently joining plaintext.
5.3 Cluster join tokens
Section titled “5.3 Cluster join tokens”Short-lived join tokens are minted by the leader (shc node token)
and presented by a joining node to bootstrap mTLS. Token generation
lives in
modules/cluster/token_router.go (the shc node token
route) backed by the single-use ledger in
modules/cluster/meshtoken_repo.go;
the handshake itself is in
modules/cluster/enroll_handler.go.
Tokens are single-use and expire quickly (default: minutes).
5.4 Mesh networking (Nebula, opt-in)
Section titled “5.4 Mesh networking (Nebula, opt-in)”When the optional Nebula mesh is enabled (shc init --nebula or
shc cluster mesh enable — it is off by default), cluster traffic
that isn’t raft replication flows through a
Nebula overlay mesh. Each node
holds a Nebula certificate signed by the cluster CA; the mesh
provides authenticated, encrypted UDP transport even across hostile
networks. Without the mesh, non-raft cluster traffic rides the
mTLS/shared-token internal API (:4003) directly on the cluster
interface. Ownership: the nebula module.
6. Secrets at rest (vault)
Section titled “6. Secrets at rest (vault)”User-facing secrets (ref+vault://… references in compose templates,
app vars marked @secret) are stored via the
vault module, which is itself multi-provider:
Local (SQLite with a cluster-distributed KEK), AWS Secrets Manager,
HashiCorp Vault, Azure Key Vault, GCP Secret Manager, 1Password.
Secrets are resolved lazily at read time by
core/config/secrets.go:
Config.GetResolved parses a ref+vault://… value via ParseSecretRef
and dispatches it through the provider Dispatcher() resolver. They are
never committed to
the rendered compose file that Docker sees — they are injected
directly as environment variables or mounted as tmpfs files at
container start.
7. Audit trail
Section titled “7. Audit trail”Every authorization decision that matters emits an audit event:
| Event | Meaning |
|---|---|
A401000AUTHFL | Authentication failed (bad/missing token). |
A403001PERMDNY | Authorization denied — user didn’t have the required scope on the resource. |
A403002TENTDNY | Tenant IDOR attempt — user tried to target a tenant they aren’t a member of. |
The full reference lives at errors.md. Audit
rows are hash-chained (see audit module) so
tampering is detectable.
8. What SHC is NOT
Section titled “8. What SHC is NOT”- Not a Kubernetes / Coolify replacement for hostile multi-tenancy. One trust boundary per node. Tenant isolation is for organizational separation, not adversarial separation.
- Not a full IAM system. Role and policy management happens in Keycloak; SHC only consumes the resulting token. Role design is the operator’s responsibility.
- Not a secret manager. Vault providers are external systems; the
vaultmodule is a thin, uniform facade.