deployment.md: auth.yaml now holds bcrypt hashes of the (still public) dev credentials with startup warnings; JWT secret from XCE_JWT_SECRET or a random per-process secret; logout revocation is per process; security.trusted_proxies for reverse proxies. security.md steering: JWT parsing, revocation, secret resolution, trusted proxies, middleware abort rule, bcrypt and role-change rule. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
77 lines
8.8 KiB
Markdown
77 lines
8.8 KiB
Markdown
# Security Standards
|
|
|
|
## Philosophy
|
|
|
|
Defense in depth, least privilege, fail closed. Authentication verifies *who*; authorization verifies *what they can do*. Centralize both — never duplicate permission checks across handlers.
|
|
|
|
## RBAC Enforcement
|
|
|
|
All protected routes use `RequirePermission` middleware attached at registration time:
|
|
|
|
```go
|
|
// In router/routers.go
|
|
hostsGroup.GET("", RequirePermission("host:view"), handler)
|
|
hostsGroup.POST("/control", RequirePermission("host:control"), handler)
|
|
```
|
|
|
|
`RequirePermission` in `controllers/rbac_check.go` resolves the user from the JWT, looks up their effective permissions (including inherited role permissions), and aborts with 403 if the required permission is absent.
|
|
|
|
**cluster_admin** bypasses all permission checks via early return — do not add new bypass paths outside this single mechanism.
|
|
|
|
**Known gap (not yet scoped): runtime RBAC mutations are not persisted.** `store.Roles`/`store.UserRoles` (`store/stores.go`) are loaded from `config/rbac.yaml` at startup via `LoadRBACConfig()`, but are also mutated at runtime with no write-back to disk or any DB — `RoleController.AddPermissionsForRole` (`controllers/roles.go`), and `store.AddRoleForUser`/`DeleteRoleForUser` (called from `AuthController.Logon`'s auto-role-assignment and the user-role-assignment endpoints in `controllers/users.go`). On apiserver restart, any role/permission granted or user/role assignment made via API since the last startup silently reverts to whatever `rbac.yaml` says, with no error or audit trail of the loss. This is the same save/restore field-completeness failure shape documented in `persistence.md` (state that "recovers correctly" while silently dropping data it depends on), just in the apiserver's in-memory RBAC store rather than a WAL/snapshot. Flagged during the `scheduler-snapshot-job-allocation` cross-component persistence survey (2026-08-27); intentionally deferred rather than fixed inline — needs its own scoped design (write-through to `rbac.yaml`? a small embedded store? explicit "runtime-only, does not survive restart" documented as accepted scope?) rather than a quick patch.
|
|
|
|
## Permission String Format
|
|
|
|
`"action:scope"` — e.g., `"queue:submit"`, `"host:view"`, `"queue:control"`.
|
|
|
|
Scopes: `all` (unrestricted), `self` (own resources), `cluster`, `hostgroup`, `self_ug`, `self_hg`.
|
|
|
|
Permissions are defined in `config/rbac.yaml`. Roles support inheritance via `includes:` — resolution is recursive. Add new permissions to the YAML first, then wire them onto routes.
|
|
|
|
## JWT Authentication
|
|
|
|
Token flow:
|
|
1. `POST /auth/logon` → validates credentials via configured auth backend → issues JWT with `{username, IP, expiry}` claims
|
|
2. Every subsequent request: `Authorization: Bearer <token>` header
|
|
3. `middleware/validatehandler.go` strips the prefix, parses with golang-jwt via `controllers.ParseTokenClaims` (HS256 only, revocation checked), **validates the IP claim against the request IP**
|
|
|
|
IP-bound tokens prevent token theft across network boundaries. Do not remove the IP validation. The request IP is `ctx.ClientIP()`, which honours `X-Forwarded-For`/`X-Real-IP` only from `app.yaml` `security.trusted_proxies` (default none, bug-1948).
|
|
|
|
Every parse path uses `controllers.JWTKeyFunc` + `JWTValidMethods()` (pinned to `JWTSigningMethod`, HS256, bug-1950). Logout revokes the token's `jti` until its expiry in an in-memory set (`controllers/token_revocation.go`, bug-1946): per process, not persisted, not shared between apiservers.
|
|
|
|
Token secret: `XCE_JWT_SECRET`, else `app.yaml` `authentication.token.secretKey` (shipped empty). Empty or the old public default `xce-api-key` means unset: a random per-process secret is generated with a startup warning (bug-1945). It must never appear in source code or logs.
|
|
|
|
Middleware that rejects a request must call `ctx.Abort()` after `ReturnError` (which only writes the response, bug-1949).
|
|
|
|
Config-file auth (`backend/auth/config_file.go`) accepts bcrypt hashes (recommended) or plaintext (compared in constant time); startup warns for plaintext entries and for shipped default credentials (bug-1947). Only `rbac:control` with scope `all` may grant or revoke user roles (bug-1952).
|
|
|
|
## Auth Backends
|
|
|
|
Backend selection is controlled by `config/auth.yaml`'s `provider:` field, matched literally against a `switch` in `backend/auth/init.go` — an unrecognized value (e.g. `configfile` without the underscore) fails apiserver startup with "unsupported auth provider". Valid identifiers: `config_file`, `pam`, `ldap`, `oauth2`. Only `config_file` (dev mode, users defined in the YAML) and `pam` (Linux + CGO only) are functional today — `ldap` and `oauth2` are unconditional stubs whose `Authenticate()` always returns "not yet implemented". All backends implement the same interface — adding a new backend means implementing that interface, not modifying middleware.
|
|
|
|
## Input Validation
|
|
|
|
Validate at the API boundary inside `preCheck{Resource}Request()` helpers — before any service or store access. Return 400 with a clear message on validation failure. Do not pass unvalidated input to downstream layers.
|
|
|
|
## Secrets
|
|
|
|
- JWT secret key: `config/auth.yaml` — should be file-system protected (e.g. chmod 0600); this is not currently enforced by the code or deploy tooling, so treat it as an operator responsibility, not a guarantee
|
|
- LDAP bind credentials: `config/auth.yaml`
|
|
- **Never log** tokens, passwords, secret keys, or full request bodies containing credentials
|
|
- Validate all required config at startup; fail fast with a clear error if missing
|
|
|
|
## Rate Limiting and IP Whitelist
|
|
|
|
Rate limiting (`middleware/rate_limit.go`) and IP whitelist (`middleware/whitelist.go`, which implements both `WhitelistURLMiddleware()` and `WhitelistIPMiddleware()`) are applied before JWT validation in the middleware chain. They use `ctx.ClientIP()` and abort with 429 / 403 respectively. Both are configured in `config/` YAML — do not hardcode thresholds.
|
|
|
|
## Transport
|
|
|
|
All daemon-to-daemon communication is over TCP. The WAL and replication wire format uses Cap'n Proto — schema files in `src/schema/`. Do not add unvalidated deserialization paths for external input.
|
|
|
|
XCE uses **server-side TLS only** — encryption without peer certificate verification. There is no mTLS and no CA-pinning anywhere in the system; peer verification is deferred as optional P3 hardening (see bug-1453, which deleted all CA-cert/client-cert plumbing from both the C tier and the Go tier as dead code). Do not reintroduce CA-cert loading, client certificates, or cert-pinning without a design doc — this posture is intentional, not an oversight.
|
|
|
|
**Clients talking to the API server** (xcecli, or any HTTP client of `apiserver`) must support **both `http://` and `https://`** — the scheme is a per-deployment/user choice (`APIServerInfo.URL`), not something the client hardcodes. When the scheme is `https://`, set `InsecureSkipVerify: true` on the TLS client config (see `xcecli/common/request/request.go`'s `NewAPIClient`) rather than attempting cert validation — the API server's cert is typically self-signed/auto-generated and the client has no CA to validate it against.
|
|
|
|
**Clients talking to an XCE controller directly** (worker, scheduler, and any other libxce-based client) need **only server-side certs — no client-side certs are required or supported**. The live connection path is `xcecd_join_cluster()` (`src/worker/src/controller_connector.c`) calling `xcecli_start`/`xcecli_join` (`src/lib/client.c`), which drives `endpoint_connect()` in `src/lib/netio.c` over an `SSL_CTX` built by `create_ssl_context(NULL, NULL)` (`src/lib/xcectx.c`) — note that `cluster.c`'s `do_connect()`/`xce_connect_cluster()`/`xce_reconnect_init`/`xce_reconnect_step` are an unused stub path (explicitly documented as "to be wired in a later task") with no callers outside `cluster.c` and its own testsuite; don't point at them as the live mechanism. `xce_cluster_config_t` has no `ca_cert_path`/client-cert fields; do not add any. TLS verification mode is `SSL_VERIFY_NONE` throughout — this matches the API-server posture above, just at the C/libxce layer instead of Go/HTTP.
|
|
|
|
**Known follow-up (not yet scoped):** a HAProxy reverse proxy in front of `apiserver` is planned to terminate HTTPS, likely with a CA-trusted cert (Let's Encrypt candidate, unconfirmed), and whether `apiserver`'s own `RunTLS`/`httpsConfig.Enabled` listener stays in the picture is also undecided. Once both are settled, revisit xcecli's blanket `InsecureSkipVerify: true`: if the client-facing endpoint ends up CA-trusted, default to real verification (Go's default `tls.Config{}` validates against the system trust store with no extra plumbing) and make skip-verify an explicit opt-in for self-signed deployments only — matching the automated security review's suggested fix. Don't implement this speculatively; the right shape depends on which of those two decisions lands where.
|