diff --git a/CHANGELOG.md b/CHANGELOG.md index dc833a5..9718373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 0.3.0-alpha.1 + +First confirmation-gated privileged service mutation milestone. + +### Added + +- typed `service.start`, `service.stop` and `service.restart` operations +- compiled mutation allowlist initially limited to `quantum-runtime.service` +- optional deployment policy that can narrow but never broaden the compiled service allowlist +- dedicated `operations.execute.mutate` permission and `mutator` service role, separate from human approval authority +- root-owned `qcored` confirmation-grant state under `/var/lib/quantum-control-broker` +- broker-side actor authentication for human approvals and mutation execution +- broker-side revalidation of immutable plan schema, digest, correlation, expiry, risk and exact normalized parameters +- single-use grant consumption before the privileged action +- fixed `systemctl -- ` execution without a shell +- precondition and postcondition service-state capture +- bounded Quantum Runtime loopback health verification for active postconditions +- deterministic transaction timeout and service polling +- one defined recovery attempt toward the observed precondition when a mutation fails +- public `POST /v1/operations/execute-approved` route gated by mutation permission +- service-mutation policy schema and configuration example +- tests for replay, actor/session/action/parameter tampering, stale plans, arbitrary-unit rejection, TCI denial and recovery behavior + +### Security posture + +- the TCI may still inspect and propose but cannot approve or execute mutations +- human approvers do not automatically receive mutation-executor authority +- ordinary read-only execution cannot satisfy a confirmation-required action with a caller-controlled string +- the privileged broker independently authenticates the approver and executor instead of trusting the public API result +- the grant remains consumed after success or failure so an interrupted or failed action cannot be blindly replayed +- `quantum-control.service`, Ollama, Apache, databases and arbitrary systemd units remain outside the mutation allowlist +- no shell, package, domain, TLS, database or container mutation is introduced + ## 0.2.0-alpha.2 Pre-mutation identity, authorization, plan, confirmation and durable audit foundation. @@ -96,7 +129,6 @@ Initial executable Quantum Control foundation. ### Not yet implemented -- mutating service operations - domains, reverse proxy and TLS - databases and containers - backups, restore and updates diff --git a/README.md b/README.md index 520d981..20d1815 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Quantum Control is the standalone Linux and server administration platform of Starlight Unit Studios. It is planned as the reusable KeyHelp replacement for the Starlight stack and, later, as a native module of Quantum CoreOS. -Current version: `0.2.0-alpha.2` +Current version: `0.3.0-alpha.1` ## Project boundary @@ -37,6 +37,8 @@ human / service / future TCI qcored privileged typed-operation broker | + root-owned grant verification + | fixed allowlisted system adapters ``` @@ -49,24 +51,29 @@ The alpha currently provides: - fixed roles and permission scopes derived server-side - TCI proposal access without execution or confirmation authority - immutable expiring operation plans with canonical SHA-256 digests -- durable single-use confirmation grants bound to exact plan/actor/action state +- durable single-use confirmation grants bound to exact plan/actor/session/action state +- root-owned grant creation and consumption inside `qcored` +- separate human approval and service mutation-executor permissions - append-only hash-chained durable audit with startup integrity verification - read-only permission-scoped audit API with no audit mutation endpoints - typed operation catalog, planning and read-only execution - read-only `system.snapshot` and `service.status` operations +- confirmation-gated `service.start`, `service.stop` and `service.restart` +- compiled mutation target currently limited to `quantum-runtime.service` +- fixed direct `systemctl` argument vectors with no shell +- service precondition/postcondition capture, Runtime health verification and bounded recovery - versioned read-only component inventory `v1alpha1` - authenticated `/v1/components` and `/v1/components/{id}` endpoints - fixed probes for KeyHelp, web servers, PHP, databases, container runtimes, Ollama, Quantum Runtime, SearXNG, Ember CoreUI and the STΛRLIGHT UNIT Game/Repack - deterministic `managed`, `external`, `disabled` and fail-safe `unknown` ownership states - bounded detection evidence, version filtering and health reporting - no guessed listener ports -- systemd hardening and protected persistent state directory +- systemd hardening and separated persistent state directories - fixtures, race tests and release-package CI -Mutating operations are intentionally absent. Any future operation that requires -confirmation currently fails closed in `qcored` until the structured grant -verifier is integrated. A caller-provided free-form confirmation string can -never satisfy that boundary. +The service mutation surface is intentionally tiny. `quantum-control.service`, +Ollama, Apache, databases and arbitrary systemd units cannot currently be +started, stopped or restarted by Quantum Control. ## Quick start @@ -74,7 +81,7 @@ Requirements: - Linux or another compatible Unix-like development environment - Go 1.23 or newer to build -- systemd for the current `service.status` and service-state inventory probes +- systemd for service inspection and the current service mutation adapter Create a local broker token: @@ -103,8 +110,8 @@ export QUANTUM_CONTROL_BROKER_SOCKET=/tmp/quantum-control-qcored.sock The public API listens on `127.0.0.1:17440` by default. When no actor registry or legacy API token is configured on loopback, Quantum Control uses a local -bootstrap identity that can access only the current read-only operator surface. -It has no audit-read or confirmation authority. +bootstrap identity that can access only the read-only operator surface. It has +no audit-read, confirmation or mutation authority. ```bash curl http://127.0.0.1:17440/healthz @@ -117,15 +124,57 @@ curl http://127.0.0.1:17440/v1/components/quantum-runtime ## Actors and TCI -An optional actor registry can identify human administrators, integration -services and the future Quantum TCI. The registry stores SHA-256 token digests, -not raw bearer tokens. +An optional actor registry identifies human administrators, integration +services, mutation executors and the future Quantum TCI. The registry stores +SHA-256 token digests, not raw bearer tokens. The TCI can be assigned the `tci-proposer` role to inspect permitted state and -create an immutable operation proposal. It cannot receive the approver role, -execute the current operation endpoint or mint a confirmation grant. +create an immutable operation proposal. It cannot receive the `mutator` or +`approver` role, execute the approved-mutation endpoint or mint a confirmation +grant. + +A human `approver` and a service `mutator` are deliberately separate roles. +The human approval token authorizes one exact immutable plan. The privileged +broker then independently authenticates the mutation executor and consumes the +single-use grant before invoking the system adapter. + +See `config/actors.example.json`, `docs/SECURITY-CONTRACTS.md` and +`docs/SERVICE-MUTATIONS.md`. + +## Transactional service control + +The first mutation flow is: + +```text +authenticated proposer + | + v +immutable plan + | + v +distinct human approval + | + v +root-owned single-use grant + | + v +qcored revalidates plan + actor + session + action + parameters + | + v +fixed systemctl argv for quantum-runtime.service + | + v +postcondition + health verification + | + v +durable audit + bounded recovery result +``` -See `config/actors.example.json` and `docs/SECURITY-CONTRACTS.md`. +The optional deployment policy in +`config/service-mutation-policy.example.json` may remove +`quantum-runtime.service` from the mutation surface. It cannot add another +service. The machine-readable policy contract is +`schema/service-mutation-policy-v1alpha1.schema.json`. ## Durable audit @@ -144,7 +193,8 @@ GET /v1/audit/integrity There is no public audit write/update/delete API. Secret-like operation parameters are redacted and arbitrary backend exception text is not stored in -durable audit records. See `docs/AUDIT.md`. +durable audit records. Mutation audit records include attempt, final result and +recovery status. See `docs/AUDIT.md`. ## Read-only adoption inventory @@ -169,7 +219,9 @@ command. Every administrative request maps to a named allowlisted action with individually validated parameters. Public actor fields are overwritten by the authenticated identity. -There is no `shell.exec` operation. +There is no `shell.exec` operation. Service mutations use a fixed +`systemctl -- ` argument vector, and the compiled mutation unit +allowlist currently contains only `quantum-runtime.service`. ## Configuration @@ -178,13 +230,15 @@ See: - `config/quantum-control.env.example` - `config/qcored.env.example` - `config/actors.example.json` +- `config/service-mutation-policy.example.json` For production, both services read the same root-owned broker token file. The file should be owned by `root:quantum-control` with mode `0640`. -The actor registry, if used, should also be protected and contain only token -digests. Plan TTL and confirmation-grant TTL are configurable but capped at 15 -minutes. +The actor registry should be root-protected when mutations are enabled. Both +processes may read the same registry, while raw confirmation-grant state is +owned only by `qcored` under `/var/lib/quantum-control-broker`. Plan TTL and +confirmation-grant TTL are configurable but capped at 15 minutes. ## Commands @@ -217,6 +271,7 @@ requests also build the amd64/arm64 release archives without publishing them. - `docs/DEPLOYMENT.md` - `docs/SECURITY.md` - `docs/SECURITY-CONTRACTS.md` +- `docs/SERVICE-MUTATIONS.md` - `docs/AUDIT.md` - `docs/LICENSE-POLICY.md` - `api/openapi.yaml` diff --git a/VERSION b/VERSION index 9528b8d..1a76028 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0-alpha.2 +0.3.0-alpha.1 diff --git a/api/openapi.yaml b/api/openapi.yaml index 4225e92..44142d1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,8 +1,8 @@ openapi: 3.1.0 info: title: Quantum Control API - version: 0.2.0-alpha.2 - description: Public typed administration API with pre-mutation identity, authorization, immutable planning and durable audit contracts. + version: 0.3.0-alpha.1 + description: Public typed administration API with identity, immutable planning, human confirmation, transactional service mutation and durable audit contracts. servers: - url: http://127.0.0.1:17440 paths: @@ -110,8 +110,8 @@ paths: '502': {$ref: '#/components/responses/BrokerError'} /v1/operations/execute: post: - summary: Execute a current read-only allowlisted operation - description: Requires operations.execute.readonly. No mutating broker operations exist in 0.2.0-alpha.2. + summary: Execute a read-only allowlisted operation + description: Requires operations.execute.readonly. Confirmation-required mutations are rejected on this route. security: [{bearerAuth: []}] requestBody: required: true @@ -119,18 +119,23 @@ paths: application/json: schema: {$ref: '#/components/schemas/OperationRequest'} responses: - '200': {description: Operation completed} + '200': {description: Read-only operation completed} '400': {description: Operation rejected by policy or validation} '401': {$ref: '#/components/responses/Unauthorized'} '403': {$ref: '#/components/responses/Forbidden'} '413': {description: Request body exceeds the configured limit} - '500': {description: Allowlisted operation failed} + '500': {description: Allowlisted read operation failed} '502': {$ref: '#/components/responses/BrokerError'} /v1/confirmations: post: summary: Issue a short-lived single-use confirmation grant - description: Requires operations.confirm and an authenticated human approver. No current mutating operation consumes these grants. + description: Requires operations.confirm and an authenticated human approver. qcored independently authenticates the approver and owns grant state. security: [{bearerAuth: []}] + parameters: + - in: header + name: X-Quantum-Session-ID + required: false + schema: {type: string, maxLength: 160} requestBody: required: true content: @@ -143,15 +148,51 @@ paths: plan_id: {type: string, maxLength: 160} responses: '201': - description: Confirmation grant issued. Raw token is returned once and is not persisted. + description: Confirmation grant issued. Raw token is returned once and only its SHA-256 digest is persisted. content: application/json: schema: {$ref: '#/components/schemas/GrantResponse'} - '400': {description: Plan is not confirmable under current policy} + '400': {description: Plan is not confirmable under current broker policy} '401': {$ref: '#/components/responses/Unauthorized'} '403': {$ref: '#/components/responses/Forbidden'} '404': {description: Plan missing or expired} - '503': {description: Confirmation storage unavailable} + '503': {description: Broker confirmation subsystem unavailable} + /v1/operations/execute-approved: + post: + summary: Execute one approved confirmation-required service mutation + description: Requires operations.execute.mutate. qcored independently authenticates the executor, revalidates the exact plan and consumes the grant before invoking the privileged adapter. + security: [{bearerAuth: []}] + parameters: + - in: header + name: X-Quantum-Session-ID + required: false + schema: {type: string, maxLength: 160} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ApprovedExecutionRequest'} + responses: + '200': + description: Approved service mutation completed and postcondition verified + content: + application/json: + schema: {$ref: '#/components/schemas/OperationResponse'} + '400': + description: Plan/grant rejected or operation semantically rejected + content: + application/json: + schema: {$ref: '#/components/schemas/OperationResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {description: Plan missing or expired} + '500': + description: Privileged action or required postcondition failed + content: + application/json: + schema: {$ref: '#/components/schemas/OperationResponse'} + '502': {$ref: '#/components/responses/BrokerError'} + '503': {description: Approved mutation subsystem unavailable} /v1/audit: get: summary: Read permission-scoped durable audit history @@ -168,8 +209,7 @@ paths: name: action schema: {type: string, maxLength: 160} responses: - '200': - description: Audit records and chain integrity metadata + '200': {description: Audit records and chain integrity metadata} '400': {description: Invalid audit filter} '401': {$ref: '#/components/responses/Unauthorized'} '403': {$ref: '#/components/responses/Forbidden'} @@ -225,7 +265,7 @@ components: application/json: schema: {$ref: '#/components/schemas/ErrorEnvelope'} BrokerError: - description: Privileged broker transport failed + description: Privileged broker transport failed or produced an ambiguous result content: application/json: schema: {$ref: '#/components/schemas/ErrorEnvelope'} @@ -274,7 +314,7 @@ components: properties: name: {type: string} value: {type: string} - risk: {type: string} + risk: {type: string, enum: [read-only, low, high, destructive]} requires_confirmation: {type: boolean} valid: {type: boolean} created_at: {type: string, format: date-time} @@ -282,13 +322,14 @@ components: error_code: {type: string} ConfirmationGrant: type: object - required: [schema, id, plan_id, plan_digest, subject_actor_id, approver, action, issued_at, expires_at] + required: [schema, id, plan_id, plan_digest, subject_actor_id, session_id, approver, action, issued_at, expires_at] properties: schema: {type: string, const: quantum.control/confirmation-grant/v1alpha1} id: {type: string} plan_id: {type: string} plan_digest: {type: string, pattern: '^[0-9a-f]{64}$'} subject_actor_id: {type: string} + session_id: {type: string} approver: {$ref: '#/components/schemas/Actor'} action: {type: string} issued_at: {type: string, format: date-time} @@ -301,7 +342,30 @@ components: grant: {$ref: '#/components/schemas/ConfirmationGrant'} token: type: string - description: One-time raw token returned to the caller. Only its SHA-256 digest is persisted. + description: One-time raw token returned to the caller. Only its SHA-256 digest is persisted by qcored. + ApprovedExecutionRequest: + type: object + additionalProperties: false + required: [plan_id, confirmation_token] + properties: + plan_id: {type: string, maxLength: 160} + confirmation_token: {type: string, maxLength: 512} + OperationResponse: + type: object + required: [request_id, action, status, risk, started_at, finished_at, audit_id] + properties: + request_id: {type: string} + action: {type: string} + status: {type: string, enum: [completed, rejected, failed]} + risk: {type: string, enum: [read-only, low, high, destructive]} + started_at: {type: string, format: date-time} + finished_at: {type: string, format: date-time} + audit_id: {type: string} + result: + type: object + additionalProperties: true + description: Bounded operation result including service pre/postcondition and recovery_status when applicable. + error: {$ref: '#/components/schemas/Problem'} InventorySnapshot: type: object required: [schema, observed_at, components] diff --git a/cmd/qcored/main.go b/cmd/qcored/main.go index 0b98c13..a2592eb 100644 --- a/cmd/qcored/main.go +++ b/cmd/qcored/main.go @@ -15,6 +15,8 @@ import ( "github.com/Starlight-Unit-Studio/Quantum-Control/internal/broker" "github.com/Starlight-Unit-Studio/Quantum-Control/internal/buildinfo" "github.com/Starlight-Unit-Studio/Quantum-Control/internal/config" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/security" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/servicecontrol" "github.com/Starlight-Unit-Studio/Quantum-Control/internal/systemprobe" ) @@ -35,14 +37,21 @@ func run(args []string) error { fmt.Println(buildinfo.Version) return nil case "check-config": - _, err := config.LoadBroker() + cfg, err := config.LoadBroker() if err != nil { return err } - fmt.Println("configuration valid") + if _, _, err := loadMutationSecurity(cfg); err != nil { + return err + } + fmt.Println("configuration and privileged security state valid") return nil case "catalog": - return json.NewEncoder(os.Stdout).Encode(broker.NewRegistry(systemprobe.Native{}).Catalog()) + registry := broker.NewRegistry(systemprobe.Native{}) + if err := registry.EnableServiceMutations(servicecontrol.Native{}, servicecontrol.HTTPHealth{}, servicecontrol.DefaultPolicy(), 30*time.Second, 250*time.Millisecond); err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(registry.Catalog()) case "serve": return serve() default: @@ -58,22 +67,27 @@ func serve() error { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) + boundary, policy, err := loadMutationSecurity(cfg) + if err != nil { + return fmt.Errorf("initialize privileged security state: %w", err) + } + registry := broker.NewRegistry(systemprobe.Native{}) + if err := registry.EnableServiceMutations(servicecontrol.Native{}, servicecontrol.HTTPHealth{}, policy, cfg.TransactionTimeout, cfg.ServicePollInterval); err != nil { + return fmt.Errorf("initialize service mutation registry: %w", err) + } listener, err := broker.ListenUnix(cfg.SocketPath) if err != nil { return err } defer listener.Close() - - registry := broker.NewRegistry(systemprobe.Native{}) server := &http.Server{ - Handler: broker.NewServer(registry, cfg.BrokerToken, cfg.RequestBodyLimit, logger).Handler(), + Handler: broker.NewServerWithSecurity(registry, cfg.BrokerToken, cfg.RequestBodyLimit, logger, boundary).Handler(), ReadHeaderTimeout: cfg.HeaderTimeout, - ReadTimeout: 15 * time.Second, - WriteTimeout: 20 * time.Second, + ReadTimeout: cfg.TransactionTimeout + 10*time.Second, + WriteTimeout: cfg.TransactionTimeout + 15*time.Second, IdleTimeout: cfg.IdleTimeout, MaxHeaderBytes: 1 << 20, } - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() errCh := make(chan error, 1) @@ -85,7 +99,6 @@ func serve() error { } errCh <- nil }() - select { case err := <-errCh: return err @@ -98,3 +111,23 @@ func serve() error { return <-errCh } } + +func loadMutationSecurity(cfg config.Broker) (broker.SecurityBoundary, servicecontrol.Policy, error) { + policy, err := servicecontrol.LoadPolicy(cfg.ServicePolicyFile) + if err != nil { + return broker.SecurityBoundary{}, servicecontrol.Policy{}, err + } + grants, err := security.OpenGrantStore(cfg.GrantPath, cfg.GrantTTL) + if err != nil { + return broker.SecurityBoundary{}, servicecontrol.Policy{}, fmt.Errorf("open broker confirmation store: %w", err) + } + var authenticator security.Authenticator + if cfg.ActorFile != "" { + registry, err := security.LoadActorRegistry(cfg.ActorFile) + if err != nil { + return broker.SecurityBoundary{}, servicecontrol.Policy{}, fmt.Errorf("load broker actor registry: %w", err) + } + authenticator = registry + } + return broker.SecurityBoundary{Actors: authenticator, Grants: grants}, policy, nil +} diff --git a/config/actors.example.json b/config/actors.example.json index 86c8a3e..5bfa14b 100644 --- a/config/actors.example.json +++ b/config/actors.example.json @@ -11,6 +11,16 @@ }, "token_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + { + "actor": { + "id": "service:mutation-executor", + "kind": "service", + "display_name": "Approved Mutation Executor", + "roles": ["mutator"], + "permissions": [] + }, + "token_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, { "actor": { "id": "tci:quantum", diff --git a/config/qcored.env.example b/config/qcored.env.example index b83db42..4f1d9d9 100644 --- a/config/qcored.env.example +++ b/config/qcored.env.example @@ -1,9 +1,25 @@ # Privileged broker Unix socket QUANTUM_CONTROL_BROKER_SOCKET=/run/quantum-control/qcored.sock -# Shared secret generated during installation. +# Shared secret generated during installation. This authenticates the +# unprivileged Control service to qcored, not a human or TCI actor. QUANTUM_CONTROL_BROKER_TOKEN_FILE=/etc/quantum-control/broker.token +# Root-owned actor and approval state. Approved mutations fail closed when no +# actor registry is configured. +# QUANTUM_CONTROL_ACTOR_FILE=/etc/quantum-control/actors.json +QUANTUM_CONTROL_GRANT_PATH=/var/lib/quantum-control-broker/grants.json +QUANTUM_CONTROL_GRANT_TTL=2m + +# Optional deployment policy. It can only narrow the compiled service mutation +# allowlist; it can never add arbitrary systemd units. +# QUANTUM_CONTROL_SERVICE_POLICY_FILE=/etc/quantum-control/service-mutation-policy.json + +# Transaction limits. The first compiled mutation target is only +# quantum-runtime.service. +QUANTUM_CONTROL_TRANSACTION_TIMEOUT=30s +QUANTUM_CONTROL_SERVICE_POLL_INTERVAL=250ms + # Limits and timeouts QUANTUM_CONTROL_BROKER_REQUEST_BODY_LIMIT=1048576 QUANTUM_CONTROL_BROKER_HEADER_TIMEOUT=10s diff --git a/config/quantum-control.env.example b/config/quantum-control.env.example index 93836db..3c70752 100644 --- a/config/quantum-control.env.example +++ b/config/quantum-control.env.example @@ -3,18 +3,19 @@ QUANTUM_CONTROL_LISTEN=127.0.0.1:17440 # Optional legacy bearer token. Required for non-loopback listeners unless an # actor registry is configured. This token maps to a service identity and never -# receives human confirmation authority. +# receives human confirmation or mutation authority. # QUANTUM_CONTROL_API_TOKEN=replace-with-a-long-random-value # Optional actor registry with SHA-256 token digests, never raw bearer tokens. +# For approved mutations the same root-owned registry is also configured for +# qcored so the privileged broker independently authenticates approvers and +# executors. # QUANTUM_CONTROL_ACTOR_FILE=/etc/quantum-control/actors.json -# Durable security state. The systemd package provisions -# /var/lib/quantum-control with mode 0700. +# Durable public audit state. Confirmation grants are NOT stored by this +# unprivileged process; qcored owns them under /var/lib/quantum-control-broker. QUANTUM_CONTROL_AUDIT_PATH=/var/lib/quantum-control/audit/audit.jsonl -QUANTUM_CONTROL_GRANT_PATH=/var/lib/quantum-control/security/grants.json QUANTUM_CONTROL_PLAN_TTL=5m -QUANTUM_CONTROL_GRANT_TTL=2m # Broker connection QUANTUM_CONTROL_BROKER_SOCKET=/run/quantum-control/qcored.sock @@ -24,4 +25,4 @@ QUANTUM_CONTROL_BROKER_TOKEN_FILE=/etc/quantum-control/broker.token QUANTUM_CONTROL_REQUEST_BODY_LIMIT=1048576 QUANTUM_CONTROL_HEADER_TIMEOUT=10s QUANTUM_CONTROL_IDLE_TIMEOUT=90s -QUANTUM_CONTROL_BROKER_TIMEOUT=15s +QUANTUM_CONTROL_BROKER_TIMEOUT=45s diff --git a/config/service-mutation-policy.example.json b/config/service-mutation-policy.example.json new file mode 100644 index 0000000..860844a --- /dev/null +++ b/config/service-mutation-policy.example.json @@ -0,0 +1,6 @@ +{ + "schema": "quantum.control/service-mutation-policy/v1alpha1", + "allowed_units": [ + "quantum-runtime.service" + ] +} diff --git a/deploy/systemd/qcored.service b/deploy/systemd/qcored.service index 01933f7..f8adf34 100644 --- a/deploy/systemd/qcored.service +++ b/deploy/systemd/qcored.service @@ -10,6 +10,8 @@ Group=quantum-control UMask=0007 RuntimeDirectory=quantum-control RuntimeDirectoryMode=0750 +StateDirectory=quantum-control-broker +StateDirectoryMode=0700 EnvironmentFile=-/etc/quantum-control/qcored.env ExecStart=/usr/local/libexec/qcored serve Restart=on-failure @@ -28,7 +30,7 @@ LockPersonality=true MemoryDenyWriteExecute=true CapabilityBoundingSet= AmbientCapabilities= -RestrictAddressFamilies=AF_UNIX +RestrictAddressFamilies=AF_UNIX AF_INET SystemCallArchitectures=native [Install] diff --git a/docs/API.md b/docs/API.md index ad33056..477ed6c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # Quantum Control API -Status: `v1` alpha subset, current implementation `0.2.0-alpha.2` +Status: `v1` alpha subset, current implementation `0.3.0-alpha.1` Public base default: `http://127.0.0.1:17440` @@ -14,30 +14,32 @@ Authorization: Bearer An optional actor registry maps bearer-token SHA-256 digests to explicit `human`, `service` or `tci` identities and fixed roles. The legacy -`QUANTUM_CONTROL_API_TOKEN` remains a service identity for compatibility. +`QUANTUM_CONTROL_API_TOKEN` remains a service identity for compatibility and +never gains mutation authority. On loopback only, when neither credential source is configured, Quantum Control -uses `service:loopback-readonly`. It can use the existing read-only operator -surface but cannot read durable audit or issue confirmations. +uses `service:loopback-readonly`. It can use the read-only operator surface but +cannot read durable audit, issue confirmations or execute mutations. -Caller-provided JSON `actor` fields are never trusted. Quantum Control replaces -them with the authenticated actor ID before forwarding a typed request to -`qcored`. +Caller-provided JSON `actor` and legacy `confirmation` fields are never trusted. +Quantum Control replaces the actor with the authenticated actor ID and clears +legacy confirmation text before forwarding a typed request to `qcored`. -Clients may optionally send a validated correlation header: +Clients may optionally send: ```http X-Quantum-Session-ID: my-session-123 ``` -It is correlation metadata, not an authentication credential. +The session ID is bound into immutable plans and confirmation grants but is not +an authentication credential. -The internal broker API is transported over a Unix socket and requires the -separate `X-Quantum-Broker-Token` service credential. It is not a public API. +The internal broker API is transported over a protected Unix socket and uses a +separate `X-Quantum-Broker-Token`. It is not a public API. ## Permission model -Important permission scopes include: +Important scopes are: ```text control.read @@ -45,13 +47,16 @@ inventory.read operations.catalog.read operations.plan operations.execute.readonly +operations.execute.mutate audit.read operations.confirm operations.propose ``` -TCI actors may receive proposal/read roles only and cannot receive -`operations.confirm`. +The `mutator` service role receives `operations.execute.mutate`. The human +`approver` role receives `operations.confirm`. These are deliberately separate. +TCI actors may receive proposal/read roles only and cannot receive either +mutation or confirmation authority. ## Public health @@ -61,42 +66,47 @@ Reports public process liveness. ### `GET /readyz` -Verifies that `qcored` and the in-process security plan subsystem are ready. +Verifies that `qcored` and the in-process operation-plan subsystem are ready. ## Product information ### `GET /v1/control/info` -Requires `control.read`. - -Reports version and explicit capability flags. +Requires `control.read` and reports version plus explicit capability flags. ## Read-only component inventory ### `GET /v1/components` -Requires `inventory.read`. - -Returns a snapshot using schema `quantum.control/component-inventory/v1alpha1`. +Requires `inventory.read` and returns +`quantum.control/component-inventory/v1alpha1`. ### `GET /v1/components/{id}` -Requires `inventory.read`. - -Runs only the fixed probe definition for the requested canonical component ID. -Unknown IDs return HTTP 404 and cannot create arbitrary commands, paths or -systemd unit probes. +Requires `inventory.read`. Unknown IDs return HTTP 404 and cannot create +arbitrary commands, paths or systemd probes. Ownership values remain `managed`, `external`, `disabled` and fail-safe -`unknown`. Listener arrays remain empty when a port cannot be safely attributed. +`unknown`. ## Operation catalog ### `GET /v1/operations` -Requires `operations.catalog.read`. +Requires `operations.catalog.read` and returns the current broker allowlist. + +Current typed operations include: -Returns the current broker allowlist and metadata for each operation. +```text +system.snapshot +service.status +service.start +service.stop +service.restart +``` + +The final three are confirmation-required and their current allowed unit list +contains only `quantum-runtime.service`. ## Immutable planning @@ -104,35 +114,24 @@ Returns the current broker allowlist and metadata for each operation. Requires `operations.plan`. -Request example: +Example mutation proposal: ```json { "request_id": "optional-client-correlation", - "action": "service.status", + "action": "service.restart", "parameters": { "unit": "quantum-runtime.service" } } ``` -The response is no longer merely the broker validation object. Quantum Control -returns `quantum.control/operation-plan/v1alpha1`, including: - -- plan ID -- canonical SHA-256 digest -- authenticated actor -- request/session correlation -- exact canonical parameter list -- risk class -- confirmation requirement -- validity and stable rejection code -- creation and expiry timestamps +Quantum Control returns `quantum.control/operation-plan/v1alpha1` containing the +plan ID, SHA-256 digest, authenticated actor, request/session correlation, +canonical parameters, risk, confirmation requirement, validity and time bounds. -The digest changes if actor, action, parameters or bound plan metadata change. - -For a TCI actor, a successful planning request is treated as a proposal and is -audited distinctly from a human/service plan. +The digest changes if actor, action, parameters, correlation or bound policy +metadata change. TCI plans are audited as proposals. ## Read-only execution @@ -140,20 +139,15 @@ audited distinctly from a human/service plan. Requires `operations.execute.readonly`. -In `0.2.0-alpha.2`, every implemented broker operation remains read-only. -TCI proposal roles do not include this permission. - -The caller's legacy `confirmation` string is cleared by the public service and -cannot become authority. `qcored` also rejects every operation marked -`requires_confirmation` until a structured confirmation-grant verifier is -explicitly connected with a future reviewed mutation. +This route remains for read-only operations. A confirmation-required operation +submitted here is rejected by `qcored`; a caller-controlled string cannot +promote it into a mutation. -## Confirmation grants +## Human confirmation ### `POST /v1/confirmations` -Requires `operations.confirm`, which is available only through a human approver -role in v1alpha1. +Requires the human-only `operations.confirm` permission. ```json { @@ -161,16 +155,81 @@ role in v1alpha1. } ``` -The referenced plan must still exist, be valid, require confirmation and have a -valid digest. The approver must be an authenticated human and must be distinct -from the subject actor under the current policy. +The public process looks up the cached plan and forwards the exact plan plus the +authenticated approver credential over the protected broker socket. `qcored` +then independently: + +- authenticates the approver +- verifies the human confirmation permission +- revalidates the plan schema, digest, expiry and current operation policy +- rejects self-approval under v1alpha1 policy +- creates one short-lived single-use grant in root-owned state + +The raw confirmation token is returned once. Only its SHA-256 digest is stored. + +## Approved mutation execution + +### `POST /v1/operations/execute-approved` + +Requires `operations.execute.mutate`. + +```json +{ + "plan_id": "plan-...", + "confirmation_token": "" +} +``` + +The public process retrieves the exact cached plan. `qcored` independently +authenticates the mutation executor, verifies mutation permission, revalidates +the plan and current allowlist, then atomically consumes the grant before any +privileged adapter is invoked. + +A grant is bound to the plan ID/digest, plan actor, session and action. Exact +normalized parameters are part of the plan digest. Changed actor, session, +action, parameters, expiry or current policy fail closed. + +The grant remains consumed after success or failure. A transport failure after +the request has been submitted is reported as an unknown outcome and is never +automatically retried. -The response contains the grant metadata plus a random raw token. The raw token -is returned once and never stored. Durable state contains only its SHA-256 -digest. Grants are short-lived and single-use. +## Service mutation operations -There are currently no mutating broker operations that consume these grants. -The contract is implemented before mutations on purpose. +### `service.start` + +Risk: `low`, confirmation required. + +### `service.stop` + +Risk: `high`, confirmation required. + +### `service.restart` + +Risk: `low`, confirmation required. + +All three currently accept exactly: + +```json +{ + "unit": "quantum-runtime.service" +} +``` + +The privileged adapter uses only fixed vectors equivalent to: + +```text +systemctl start -- quantum-runtime.service +systemctl stop -- quantum-runtime.service +systemctl restart -- quantum-runtime.service +``` + +No shell is involved. A deployment policy may remove the Runtime unit but may +not add another systemd unit. + +Before the action, `qcored` captures service state. Afterward it waits for the +required active/inactive postcondition. Active Runtime postconditions also +require HTTP 200 from the fixed loopback health endpoint. Results include +bounded recovery metadata. See `docs/SERVICE-MUTATIONS.md`. ## Durable audit @@ -186,18 +245,14 @@ actor_id= action= ``` -Response contains current integrity metadata plus matching records. - ### `GET /v1/audit/integrity` -Requires `audit.read`. - -Returns record count, current chain head hash and verification state. - -There is intentionally no POST, PUT, PATCH or DELETE audit endpoint. +Requires `audit.read` and returns record count, chain head and verification +state. -Durable records store stable error codes and redacted parameters rather than raw -backend errors or secret values. +There is intentionally no public audit mutation endpoint. Mutation records +capture proposal/plan, approval, attempt, final state, stable error code and +recovery/rollback status without storing raw secret-like parameters. ## Convenience reads @@ -207,39 +262,19 @@ Requires `control.read` and executes `system.snapshot`. ### `GET /v1/services/{unit}` -Requires `control.read` and executes `service.status` after broker-side unit -validation. - -## Current broker operations - -### `system.snapshot` - -Risk: `read-only` - -Parameters: none. - -### `service.status` - -Risk: `read-only` - -Parameters: - -```json -{ - "unit": "quantum-runtime.service" -} -``` - -The unit must match a strict identifier policy. It is passed to a fixed -`systemctl show` argument vector and never to a shell. +Requires `control.read` and executes read-only `service.status` after broker-side +unit validation. -## Current write surface +## Explicitly unsupported write surface ```text -service restart: not implemented -domain changes: not implemented -TLS changes: not implemented -database changes: not implemented -package changes: not implemented -shell execution: permanently unsupported +quantum-control self-restart: unsupported +Ollama mutation: unsupported +arbitrary systemd units: unsupported +domain/reverse proxy changes: unsupported +TLS changes: unsupported +database changes: unsupported +package changes: unsupported +container lifecycle changes: unsupported +shell execution: permanently unsupported ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21c5f85..a4d70d3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Quantum Control Architecture -Status: `0.1.0-alpha.1` foundation +Status: `0.3.0-alpha.1` ## Responsibility @@ -20,59 +20,152 @@ It does not own: ## Process boundary ```text +human / service / future TCI + | + v quantum-control unprivileged HTTP/API and later UI + actor policy, immutable plans, public audit | - | authenticated HTTP over Unix socket + | authenticated HTTP over protected Unix socket v qcored narrowly privileged operation broker + root-owned grant state + independent actor verification | v allowlisted adapters - systemd, web server, database, firewall, backup tools + read probes + typed service lifecycle adapter + | + v +systemd / future explicitly reviewed platform adapters ``` The public process cannot directly perform privileged changes. The broker does -not understand free-form instructions. It receives only a typed action and a -bounded parameter map. +not understand free-form instructions. It receives only typed operations and +bounded structured data. + +The broker token authenticates the public Control service to `qcored`. It is +separate from human, service-executor and TCI actor credentials. ## Operation lifecycle +Read-only requests use: + ```text request - -> schema and action validation - -> plan - -> authorization and confirmation when required - -> fixed adapter execution - -> health verification - -> audit result - -> rollback where the operation supports it + -> actor/permission validation + -> typed broker validation + -> fixed read adapter + -> durable audit result +``` + +Confirmation-required service mutations use: + +```text +request or TCI proposal + -> broker policy validation + -> immutable expiring plan + -> human review and confirmation + -> root-owned single-use grant + -> mutation executor authentication + -> qcored plan + current-policy revalidation + -> atomic grant consumption + -> fixed privileged adapter + -> postcondition/health verification + -> durable audit result + -> bounded recovery where defined ``` -The alpha implements only the validation, planning and read-only execution -portion of this lifecycle. +A submitted mutation is never automatically retried after an ambiguous broker +transport result. -## Initial allowlist +## Current operation allowlist + +Read-only: + +```text +system.snapshot +service.status +``` + +Confirmation-required: ```text -system.snapshot read-only bounded machine information -service.status read-only systemd unit state +service.start +service.stop +service.restart ``` +The compiled mutation target set currently contains only: + +```text +quantum-runtime.service +``` + +A root-controlled deployment policy may narrow this set. It cannot broaden it. There is deliberately no generic command operation. +## Privileged service adapter + +The first mutation adapter selects the executable and lifecycle verb in code. +For the only current mutation target, production invokes an argument vector +equivalent to one of: + +```text +systemctl start -- quantum-runtime.service +systemctl stop -- quantum-runtime.service +systemctl restart -- quantum-runtime.service +``` + +No shell parser receives actor, user or model text. + +The adapter captures service state before and after execution. Active Runtime +postconditions additionally use a fixed loopback health endpoint. Recovery is +one bounded action toward the observed precondition when a safe policy is +defined, not a blind retry of the failed mutation. + ## Authentication layers 1. The public API binds to loopback by default. -2. Every remote binding requires a bearer token of at least 32 characters. There is no unauthenticated override. -3. `qcored` accepts requests only through a Unix socket. -4. Socket filesystem permissions restrict local callers. -5. A separate broker token authenticates the Control process to `qcored`. -6. Future user roles and confirmation grants exist above the broker token. +2. Every remote binding requires a configured credential source. +3. Public actor credentials identify `human`, `service` or `tci` actors. +4. `qcored` accepts requests only through a protected Unix socket. +5. A separate broker token authenticates `quantum-control` to `qcored`. +6. `qcored` independently authenticates the human approver and mutation executor for privileged writes. +7. Human confirmation and mutation execution are distinct permissions. +8. Root-owned confirmation-grant state is inaccessible to the unprivileged Control process. + +The broker token proves service-to-broker identity. It is never a substitute +for per-actor policy or per-operation confirmation. + +## State ownership -The broker token proves service identity. It is not a replacement for user -roles, multi-factor confirmation or per-operation policy. +```text +quantum-control + ephemeral operation-plan cache + append-only public audit + +qcored + root-owned durable confirmation-grant store + current privileged mutation policy + +systemd / Runtime + actual service state +``` + +The public plan cache is intentionally ephemeral. Losing it forces a new plan +and review. Losing grant state invalidates outstanding approvals. Neither case +silently grants authority. + +## TCI boundary + +Quantum TCI integrates through the same public actor and plan contracts as +other clients. It may inspect permitted state and create proposals, but it +cannot hold the human approval or mutation-executor permissions. + +Model output never becomes executable command text. TCI support belongs in +typed proposal/context adapters, not in a root shell bridge. ## Integration model @@ -82,7 +175,8 @@ Quantum Control is developed and released independently before Quantum CoreOS. normal Linux server -> Quantum Control Quantum CoreOS -> same Quantum Control release + native profile Ember CoreUI -> optional status/deployment integration -Quantum TCI -> typed plans and requests through Control policy +Quantum Runtime -> first explicitly supported service mutation target +Quantum TCI -> typed proposals through Control policy ``` CoreOS-specific optimization belongs in adapters and deployment profiles, not a @@ -91,11 +185,17 @@ private fork. ## Package boundaries ```text -cmd/quantum-control unprivileged process -cmd/qcored broker process -internal/control public API -internal/broker operation registry, server and client -internal/protocol stable typed messages -internal/systemprobe fixed read-only system adapters -internal/config secure process configuration +cmd/quantum-control unprivileged process +cmd/qcored privileged broker process +internal/control public API, plan cache and durable audit integration +internal/broker operation registry, broker server/client, approval boundary +internal/protocol stable typed messages +internal/security actors, plans, grants and audit contracts +internal/systemprobe fixed read-only system adapters +internal/servicecontrol fixed service lifecycle and Runtime health adapters +internal/inventory read-only component discovery +internal/config secure process configuration ``` + +Future mutating domains must pass the same explicit security and transactional +gate before receiving a privileged adapter. diff --git a/docs/AUDIT.md b/docs/AUDIT.md index 290924a..d49398e 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -2,7 +2,8 @@ Status: `v1alpha1` -Quantum Control records security-relevant planning and execution metadata in an append-only local audit chain. +Quantum Control records security-relevant planning, approval and execution +metadata in an append-only local audit chain. ## Storage @@ -12,36 +13,65 @@ Default path: /var/lib/quantum-control/audit/audit.jsonl ``` -The packaged service receives a private systemd state directory. Audit files are created with mode `0600`. +The packaged public service receives a private systemd state directory. Audit +files are created with mode `0600`. Each line is one `quantum.control/audit-record/v1alpha1` JSON object. +The root-owned confirmation-grant state is separate and lives under +`/var/lib/quantum-control-broker`. It is not a replacement for public durable +audit history. + ## Integrity chain -Every record contains: +Every record contains the authenticated actor plus bounded security metadata, +including monotonically increasing sequence, audit ID, UTC time, +request/session/plan correlation, action/risk, final status, stable error code, +redacted parameters, `previous_hash` and `entry_hash`. + +Mutation records may also include `rollback_status`, which currently carries the +bounded service recovery state such as `not_required`, `succeeded`, `failed`, +`not_defined` or `unknown`. + +`entry_hash` is a SHA-256 digest over the canonical record excluding +`entry_hash` itself. It includes `previous_hash`, forming a chain. -- monotonically increasing `sequence` -- stable audit `id` -- UTC timestamp -- authenticated actor identity and kind -- request/session/plan correlation where applicable -- action and risk class -- stable status and error code -- redacted parameters -- `previous_hash` -- `entry_hash` +Quantum Control verifies every line and the full hash chain during startup. It +never skips a damaged line, silently truncates history or rewrites an old entry. -`entry_hash` is a SHA-256 digest over the canonical record excluding `entry_hash` itself. It includes `previous_hash`, forming a chain. +## Mutation event sequence -Quantum Control verifies every line and the full hash chain during startup. It never skips a damaged line, silently truncates history or rewrites an old entry. +A normal reviewed service mutation produces evidence across the flow: + +```text +plan.created or proposal.created +confirmation.issued +operation.attempt +operation.completed or operation.failed +``` + +If the public process loses broker transport after the approved request has +already been submitted, it records an `operation.unknown` outcome with +`rollback_status=unknown`. It does not guess whether the privileged action ran +and does not automatically retry it. + +The privileged grant has already been consumed before the service adapter is +invoked, so replaying the same token cannot turn an ambiguous result into an +uncontrolled second mutation. ## Redaction -Parameter names that indicate secrets are stored as `[REDACTED]`. This includes password, passphrase, token, secret, credential, API-key and private-key style names. +Parameter names that indicate secrets are stored as `[REDACTED]`. This includes +password, passphrase, token, secret, credential, API-key and private-key style +names. -Arbitrary backend error strings are not written to the durable record. Stable error codes are stored instead. +Raw confirmation tokens and actor bearer tokens are not placed into public audit +records. Arbitrary backend error strings are not written to the durable record; +stable error codes are stored instead. -This is defense in depth, not permission to send secrets as operation parameters. Typed operations should avoid secret-bearing parameters whenever a separate protected credential channel can be used. +This is defense in depth, not permission to send secrets as operation +parameters. Typed operations should avoid secret-bearing parameters whenever a +separate protected credential channel can be used. ## Query API @@ -57,24 +87,29 @@ GET /v1/audit/integrity ```text limit 1..500 actor_id exact actor ID - action exact action +action exact action ``` There is intentionally no POST, PUT, PATCH or DELETE audit API. ## Retention policy for v1alpha1 -Quantum Control performs no automatic audit deletion or rotation in this release. +Quantum Control performs no automatic audit deletion or rotation in this +release. -This avoids introducing an unreviewed mechanism that could destroy security evidence. Operators must provision enough storage for the audit history. +This avoids introducing an unreviewed mechanism that could destroy security +evidence. Operators must provision enough storage for the audit history. -A future rotation design must preserve verifiable chain boundaries and archive metadata before it can become an automated feature. +A future rotation design must preserve verifiable chain boundaries and archive +metadata before it can become an automated feature. ## Export policy The permission-scoped read API is the supported online export surface. -For forensic/offline export, copy the JSONL file while Quantum Control is stopped or from a filesystem snapshot. Preserve file metadata and record the final `head_hash` returned by `/v1/audit/integrity` when possible. +For forensic/offline export, copy the JSONL file while Quantum Control is +stopped or from a filesystem snapshot. Preserve file metadata and record the +final `head_hash` returned by `/v1/audit/integrity` when possible. Do not transform the source audit file in place. @@ -89,10 +124,14 @@ If startup reports an audit integrity failure: 5. restore a known-good audit file if available 6. otherwise wait for an explicit audited lineage-reset procedure in a future release -The v1alpha1 service does not contain an automatic reset switch. - -Deleting or replacing the audit file merely to make the service start is not a supported recovery procedure. +The v1alpha1 service does not contain an automatic reset switch. Deleting or +replacing the audit file merely to make the service start is not a supported +recovery procedure. ## Security limitation -The chain detects modification relative to the state Quantum Control later reads. It does not defeat a fully compromised root account that can replace the program and all local state together. Its purpose is to make ordinary API clients, services and AI actors unable to rewrite administrative history through Quantum Control. +The chain detects modification relative to the state Quantum Control later +reads. It does not defeat a fully compromised root account that can replace the +program and all local state together. Its purpose is to make ordinary API +clients, services and AI actors unable to rewrite administrative history through +Quantum Control. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 0495780..c5c01c4 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,7 +2,9 @@ Status: manual alpha deployment only -Version `0.1.0-alpha.1` is a read-only control-plane foundation. It is not yet a production KeyHelp replacement and does not include an automated installer. +Version `0.3.0-alpha.1` includes the first narrowly scoped privileged service +mutation path. It is not yet a production KeyHelp replacement and does not +include an automated installer. ## Intended filesystem layout @@ -12,25 +14,35 @@ Version `0.1.0-alpha.1` is a read-only control-plane foundation. It is not yet a /etc/quantum-control/quantum-control.env /etc/quantum-control/qcored.env /etc/quantum-control/broker.token +/etc/quantum-control/actors.json +/etc/quantum-control/service-mutation-policy.json /etc/systemd/system/quantum-control.service /etc/systemd/system/qcored.service /run/quantum-control/qcored.sock +/var/lib/quantum-control/audit/audit.jsonl +/var/lib/quantum-control-broker/grants.json ``` ## Service identity -Create a dedicated system group and unprivileged service account according to the conventions of the target distribution: +Create a dedicated system group and unprivileged service account: ```text group: quantum-control user: quantum-control ``` -`qcored` runs as root but with an empty capability bounding set and a strict read-only service profile in the current foundation. The public `quantum-control` process runs as the dedicated unprivileged user. +The public `quantum-control` process runs as this unprivileged account. +`qcored` runs as root inside the hardened systemd unit because it is the only +component allowed to invoke privileged typed system adapters. + +`qcored` does not expose a shell or arbitrary command API. The initial compiled +mutation target is only `quantum-runtime.service`. ## Broker token -Generate at least 32 random bytes and store the encoded result in the broker token file. The same file is read by both services. +Generate at least 32 random bytes and store the encoded result in the broker +token file. The same file is read by both services. Required ownership: @@ -40,7 +52,71 @@ group: quantum-control mode: 0640 ``` -Never reuse the public API token as the broker token. +Never reuse an actor/public API token as the broker token. + +## Actor registry + +Approved mutations require an actor registry configured for both processes: + +```text +QUANTUM_CONTROL_ACTOR_FILE=/etc/quantum-control/actors.json +``` + +The file contains SHA-256 digests of actor bearer tokens, not plaintext tokens. +A practical ownership profile is: + +```text +owner: root +group: quantum-control +mode: 0640 +``` + +The public service needs the registry to map the incoming credential to its +public actor/permission set. `qcored` reads the same root-controlled policy and +independently authenticates approvers and mutation executors. + +At minimum keep human approval and service mutation execution on distinct +credentials. See `config/actors.example.json`. + +## Root-owned confirmation state + +`qcored.service` provisions: + +```text +StateDirectory=quantum-control-broker +StateDirectoryMode=0700 +``` + +The default durable grant path is: + +```text +/var/lib/quantum-control-broker/grants.json +``` + +The unprivileged Control process must not receive write access to this file. +Only SHA-256 token digests and grant metadata are persisted. + +The public durable audit remains in the separate `quantum-control` state +directory. + +## Mutation policy + +The built-in allowlist contains only: + +```text +quantum-runtime.service +``` + +An optional root-controlled file may narrow that list: + +```text +QUANTUM_CONTROL_SERVICE_POLICY_FILE=/etc/quantum-control/service-mutation-policy.json +``` + +Use `config/service-mutation-policy.example.json` as the shape. A policy file +that names any unit outside the compiled allowlist causes startup/configuration +validation to fail. It cannot turn a syntactically valid arbitrary unit into a +privileged target. ## Public listener @@ -50,7 +126,9 @@ The default is loopback-only: QUANTUM_CONTROL_LISTEN=127.0.0.1:17440 ``` -A non-loopback address is rejected unless `QUANTUM_CONTROL_API_TOKEN` contains at least 32 characters. Remote use additionally requires TLS through a carefully configured reverse proxy. The alpha service itself does not terminate TLS. +A non-loopback address is rejected unless a valid public credential source is +configured. Remote use additionally requires TLS through a carefully configured +reverse proxy. The alpha service itself does not terminate TLS. ## Build @@ -63,24 +141,48 @@ The resulting binaries are written to `bin/` by `make build`. ## Service order -1. Install configuration and the protected broker token. -2. Start `qcored`. -3. Confirm that `/run/quantum-control/qcored.sock` exists with group access. -4. Start `quantum-control`. -5. Check `/healthz` and `/readyz`. -6. Verify the operation catalog before using convenience endpoints. +1. Install the root-controlled environment files, actor registry and broker token. +2. Optionally install a narrowing service-mutation policy. +3. Start `qcored`. +4. Confirm that `/run/quantum-control/qcored.sock` exists with group access. +5. Start `quantum-control`. +6. Check `/healthz` and `/readyz`. +7. Inspect the operation catalog before issuing a mutation plan. -## Read-only validation +## Catalog validation -The current release should expose only: +The expected 0.3 alpha operation catalog includes: ```text system.snapshot service.status +service.start +service.stop +service.restart ``` -Any `shell.exec`, service mutation, file mutation or package-management request must be rejected. Stop deployment immediately if the catalog contains an unexpected operation. +The mutation definitions must advertise only `quantum-runtime.service` in the +allowed value set. Stop deployment if another mutation unit appears +unexpectedly. + +Any `shell.exec`, arbitrary command, file mutation or package-management action +must still be rejected. + +## Approved mutation sequence + +Use a proposer/operator credential to create the immutable plan. Use a distinct +human approver credential to call `/v1/confirmations`. Submit the returned +single-use token through `/v1/operations/execute-approved` using a `mutator` +service identity. + +Do not retry an approved mutation automatically after an HTTP/network failure. +The grant is consumed in `qcored` before the privileged adapter executes. An +ambiguous result requires status/audit inspection and a new plan plus a new +approval if another change is necessary. ## Upgrade boundary -Until a transactional installer exists, upgrade the two binaries together from one tested release. Do not mix protocol versions. Preserve local environment files and the broker token outside release archives. +Until a transactional installer exists, upgrade both binaries together from one +tested release. Do not mix broker/public protocol versions. Preserve local +environment files, actor registry, broker token, audit data and root-owned grant +state outside release archives. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8b0ff0a..2c2b015 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -43,18 +43,27 @@ Remaining read-only/adoption work may proceed independently: ## 0.3 transactional service management -The first mutating milestone may begin only after `0.2.0-alpha.2` is merged and verified. +Implemented in `0.3.0-alpha.1`: -Planned: - -- broker-side structured confirmation-grant verifier +- broker-side structured confirmation-grant verification and root-owned grant state - explicit `service.start`, `service.stop` and `service.restart` actions -- per-action role and confirmation policy -- maintenance windows -- precondition and postcondition health checks -- durable audit records for proposal, approval, attempt and result -- rollback/recovery behavior -- no generic command execution +- compiled mutation allowlist initially limited to `quantum-runtime.service` +- deployment policy that may narrow but never broaden the compiled allowlist +- separate human approval and mutation-executor permissions +- TCI proposal-only boundary retained across the mutation path +- immutable-plan revalidation inside `qcored` +- grant consumption before privileged execution with durable replay protection +- fixed direct `systemctl` argument vectors without shell execution +- precondition, postcondition and Runtime health checks +- bounded deterministic transaction timeouts and one defined recovery attempt +- durable audit records for proposal, approval, attempt, result and recovery status +- ambiguous transport outcomes are not automatically retried + +Deferred within the 0.3 line: + +- maintenance-window policy +- additional explicitly reviewed Quantum/Starlight service targets +- richer service-specific postcondition profiles ## 0.4 web and TLS management diff --git a/docs/SECURITY-CONTRACTS.md b/docs/SECURITY-CONTRACTS.md index 0a7f0ee..e09b2eb 100644 --- a/docs/SECURITY-CONTRACTS.md +++ b/docs/SECURITY-CONTRACTS.md @@ -1,6 +1,7 @@ # Quantum Control Security Contracts v1alpha1 -This document defines the application-level authorization contracts that must exist before Quantum Control gains a mutating broker operation. +This document defines the application-level authorization contracts used by the +first reviewed Quantum Control mutation path. ## Actor registry @@ -10,7 +11,8 @@ Schema: schema/actor-registry-v1alpha1.schema.json ``` -The registry identifies `human`, `service` and `tci` actors. Credentials are stored as SHA-256 bearer-token digests rather than raw tokens. +The registry identifies `human`, `service` and `tci` actors. Credentials are +stored as SHA-256 bearer-token digests rather than raw tokens. Generate a new token and its digest outside the repository, for example: @@ -19,9 +21,9 @@ TOKEN="$(openssl rand -hex 32)" printf '%s' "$TOKEN" | sha256sum ``` -Store the raw token in the appropriate protected client credential facility and only the digest in the actor registry. - -`config/actors.example.json` contains deliberately unusable placeholder hashes. They must be replaced before use. +Store the raw token in the appropriate protected client credential facility and +only the digest in the actor registry. `config/actors.example.json` contains +deliberately unusable placeholder hashes. ## Roles @@ -31,14 +33,25 @@ The v1alpha1 role set is fixed in code: |---|---| | `reader` | system/control metadata and inventory reads | | `operator` | read access plus planning and current read-only execution | +| `mutator` | service identity allowed to submit an already approved mutation | | `auditor` | control metadata and durable audit reads | -| `approver` | operator/auditor abilities plus human confirmation authority | +| `approver` | human confirmation authority plus operator/auditor reads | | `tci-proposer` | scoped reads and plan/proposal creation only | -| `service` | compatibility/integration service access to current read-only APIs | +| `service` | compatibility/integration access to read-only APIs | + +Clients do not submit trusted permissions. The server derives permissions from +the authenticated actor's configured roles. + +The critical separation is: -Clients do not submit trusted permissions. The server derives permissions from the authenticated actor's configured roles. +```text +approver -> operations.confirm +mutator -> operations.execute.mutate +``` -TCI actors are explicitly restricted to `tci-proposer` and `reader` roles. +A human approval does not automatically become mutation execution authority. +TCI actors are explicitly restricted to `tci-proposer` and `reader` roles and +cannot obtain either permission. ## Operation plans @@ -48,9 +61,19 @@ Schema: schema/operation-plan-v1alpha1.schema.json ``` -An operation plan is a time-limited review snapshot generated after `qcored` validates an allowlisted operation. The public server computes a canonical SHA-256 digest over the exact actor, action, parameters, risk and correlation metadata. +An operation plan is a time-limited review snapshot generated after `qcored` +validates an allowlisted operation. The public server computes a canonical +SHA-256 digest over the exact actor, action, normalized parameters, risk, +confirmation requirement, validity, request/session correlation and time +bounds. + +The plan cache is intentionally ephemeral. A public-process restart invalidates +unexecuted plans and forces a new review. -The plan cache is intentionally ephemeral. A restart invalidates unexecuted plans and forces a new review rather than silently restoring old intent. +Before a mutation, `qcored` independently verifies the plan schema and digest, +checks expiry, reconstructs the exact parameters and re-runs the current broker +operation policy. A correctly signed digest cannot make an operation executable +when current broker policy no longer allows it. ## Confirmation grants @@ -60,22 +83,63 @@ Schema: schema/confirmation-grant-v1alpha1.schema.json ``` -The grant store persists single-use approval state. Raw grant tokens are returned once and only their SHA-256 digests are stored. +The root-owned `qcored` grant store persists single-use approval state. Raw +grant tokens are returned once and only their SHA-256 digests are stored. -A grant is valid only when all bound values still match: +A grant is bound to: ```text plan ID plan digest subject actor +session ID exact action expiry unused state ``` -The v1alpha1 grant store can issue and consume these objects, but `qcored` deliberately refuses every confirmation-required execution until a broker-side verifier is integrated with the first reviewed mutating operation. +Exact normalized operation parameters are already part of the plan digest. +Changing a parameter changes the digest and invalidates the old grant. + +The human approver is independently authenticated inside `qcored`. Under the +current policy the approver must be distinct from the plan subject actor. + +## Approved execution + +`POST /v1/operations/execute-approved` is the only public path for the first +confirmation-required mutations. The public service forwards the cached plan, +single-use token and authenticated mutation-executor credential over the +protected broker socket. + +`qcored` then: + +1. authenticates the executor independently, +2. rejects TCI actors and actors without `operations.execute.mutate`, +3. revalidates the exact immutable plan and current operation policy, +4. verifies and atomically consumes the matching grant, +5. only then invokes the fixed privileged system adapter. -This prevents an untrusted API client or TCI from turning a free-form `confirmation` string into authority. +The grant is consumed before privileged execution and remains consumed whether +the operation succeeds or fails. This prevents replay after a process restart +or ambiguous network response. + +The legacy free-form `confirmation` field is still cleared by the public API and +is never accepted as authority. + +## Service mutation policy + +Schema: + +```text +schema/service-mutation-policy-v1alpha1.schema.json +``` + +The compile-time mutation unit set currently contains only +`quantum-runtime.service`. A deployment policy may narrow that set. It cannot +broaden it. + +The privileged adapter uses a fixed executable and one of the fixed lifecycle +verbs. No shell is used. See `docs/SERVICE-MUTATIONS.md`. ## Audit records @@ -85,7 +149,10 @@ Schema: schema/audit-record-v1alpha1.schema.json ``` -Plans/proposals, confirmation issuance and read-only execution outcomes can be recorded with authenticated actor identity. Audit persistence and recovery rules are documented in `docs/AUDIT.md`. +Plans/proposals, confirmation issuance, mutation attempts and final results are +recorded with authenticated actor identity. Mutation results also carry the +recovery/rollback status. Audit persistence and recovery rules are documented +in `docs/AUDIT.md`. ## Session correlation @@ -95,15 +162,20 @@ Clients may provide: X-Quantum-Session-ID: ``` -If omitted, Quantum Control derives a session correlation value from the generated request ID. Session IDs are correlation metadata, never authentication credentials. +If omitted, Quantum Control derives a correlation value from the generated +request ID. Session IDs are correlation metadata, never authentication +credentials, but once placed in a plan they are part of the confirmation +binding. ## Current mutation state ```text -mutation operations: NONE -shell execution: NONE -confirmation-required execution: FAIL CLOSED -TCI confirmation permission: IMPOSSIBLE BY ROLE POLICY +service.start: quantum-runtime.service only +service.stop: quantum-runtime.service only +service.restart: quantum-runtime.service only +arbitrary systemd mutation: REJECTED +legacy confirmation strings: NEVER AUTHORITY +TCI confirmation permission: IMPOSSIBLE BY ROLE POLICY +TCI mutation permission: IMPOSSIBLE BY ROLE POLICY +shell execution: NONE ``` - -The security contracts are infrastructure for later reviewed mutations, not evidence that write operations already exist. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 857e6db..0ec1387 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,8 +1,11 @@ # Quantum Control Security Baseline -Status: `0.2.0-alpha.2` +Status: `0.3.0-alpha.1` -Quantum Control is intended to become a system administration platform. Its security boundary is established before any mutating feature is added. +Quantum Control is intended to become a system administration platform. Its +security boundary is established before broad administrative functionality is +added, and the first reviewed mutation path now exercises that boundary against +one explicitly supported service. ## Process separation @@ -16,17 +19,24 @@ human / service / future TCI quantum-control unprivileged API | - immutable plan + audit + immutable plan + durable audit | protected Unix socket + broker token | qcored privileged typed-operation broker | + root-owned grants + plan revalidation + | fixed allowlisted adapters ``` -The public service never receives direct root access. `qcored` never accepts command lines, scripts or arbitrary executable names. +The public service never receives direct root access. `qcored` never accepts +command lines, scripts or arbitrary executable names. + +The first mutation adapter can start, stop or restart only +`quantum-runtime.service`. It constructs a fixed `systemctl` argument vector and +never invokes a shell. ## Identity and authorization @@ -36,13 +46,37 @@ Quantum Control distinguishes three actor kinds: - `service` - `tci` -Roles expand into fixed permission scopes in code. A caller cannot send a role or permission in an API request and have it trusted. +Roles expand into fixed permission scopes in code. A caller cannot send a role +or permission in an API request and have it trusted. + +The optional actor registry stores only SHA-256 bearer-token digests. Raw actor +tokens must never be placed in the registry, repository, release archive, audit +log or diagnostics. + +When the API is loopback-only and no actor credential source is configured, the +bootstrap actor `service:loopback-readonly` may use read-only inventory and +broker operations. It has no audit-read, confirmation or mutation permission and +this bootstrap mode is never sufficient for a non-loopback listener. + +The legacy single API token remains supported as a service identity for +compatibility. It does not receive human confirmation or mutation authority. + +## Approval and execution separation + +The first mutation flow uses two distinct permissions: -The optional actor registry stores only SHA-256 bearer-token digests. Raw actor tokens must never be placed in the registry, repository, release archive, audit log or diagnostics. +```text +operations.confirm +operations.execute.mutate +``` -When the API is loopback-only and no actor credential source is configured, the bootstrap actor `service:loopback-readonly` may use the existing read-only inventory and broker operations. It has no audit-read or confirmation permission and this bootstrap mode is never sufficient for a non-loopback listener. +The human `approver` role receives confirmation authority but not mutation +execution authority. The service `mutator` role may submit an already approved +plan but cannot approve it. -The legacy single API token remains supported as a service identity for compatibility. It does not receive human confirmation authority. +Both identities are authenticated again inside `qcored`. The broker does not +trust the public API merely because it says that a request was approved or +executed by a particular actor. ## TCI boundary @@ -58,18 +92,19 @@ A TCI actor may receive only the `tci-proposer` and/or `reader` roles. It may: It may not: - receive `operations.confirm` +- receive `operations.execute.mutate` - mint its own confirmation grant -- execute the current read-only operation endpoint -- manufacture a human identity -- modify actor credentials -- bypass plan expiry or plan digest checks +- execute the approved-mutation endpoint +- manufacture a human or service executor identity +- modify actor credentials or service mutation policy +- bypass plan expiry, plan digest or postcondition checks - pass model text to a shell Model output is always untrusted data. ## Immutable plan contract -Planning produces a separate `quantum.control/operation-plan/v1alpha1` object. +Planning produces `quantum.control/operation-plan/v1alpha1`. Its SHA-256 digest binds: @@ -86,69 +121,126 @@ Its SHA-256 digest binds: Changing the actor, action, parameters or bound metadata invalidates the digest. -Plans expire. The current default is five minutes and configuration is capped at fifteen minutes. +Plans expire. The default remains five minutes and configuration is capped at +fifteen minutes. + +Before approved execution, `qcored` independently verifies the schema and digest, +checks time bounds, reconstructs exact parameters and re-runs current broker +policy. A previously valid plan cannot bypass a later policy restriction. ## Confirmation grant contract -Confirmation grants are deliberately separate from operation plans. +Confirmation grants are separate from operation plans and are now owned by +`qcored`. A grant is: -- issued only by an authenticated `human` actor with `operations.confirm` +- issued only after `qcored` authenticates a `human` actor with `operations.confirm` - bound to one plan ID and digest -- bound to the subject actor and exact action +- bound to the plan subject actor, session and exact action +- indirectly bound to exact normalized parameters through the plan digest - short-lived, with a maximum configured TTL of fifteen minutes - backed by a random token whose raw value is returned only to the caller - stored on disk only as a SHA-256 token digest - single-use and durably marked consumed -The v1alpha1 policy also requires the human approver to be different from the plan's subject actor. This is intentionally stricter than the minimum requirement and can be revisited only through an explicit policy change. +The v1alpha1 policy also requires the human approver to be different from the +plan subject actor. + +`qcored` consumes the grant before invoking the privileged adapter. The grant +remains consumed whether the action succeeds or fails, so an HTTP retry or +process restart cannot replay the same authorization. -A TCI can never issue a grant. +A caller-controlled legacy `confirmation` string is still cleared by the public +service and cannot satisfy this contract. -### Fail-closed broker boundary +## Transactional service mutation boundary -`qcored` does not yet consume confirmation grants because there are no mutating operations. +Current confirmation-required actions are: + +```text +service.start +service.stop +service.restart +``` -Any future operation marked `requires_confirmation` is currently rejected at execution with `confirmation_verifier_required`, even if a caller sends an arbitrary legacy `confirmation` string. The first mutating operation may be introduced only together with the broker-side structured grant verifier. +The compile-time mutation target set contains only: + +```text +quantum-runtime.service +``` + +An optional root-controlled deployment policy may narrow that set. It cannot add +another systemd unit. + +The adapter constructs only these forms: + +```text +systemctl start -- quantum-runtime.service +systemctl stop -- quantum-runtime.service +systemctl restart -- quantum-runtime.service +``` + +The action captures a bounded precondition state, performs the one selected +operation and waits for the required postcondition. Active Runtime postconditions +also require HTTP 200 from the fixed loopback health endpoint. + +If a transaction fails, recovery is deliberately limited to one defined attempt +toward the observed precondition where policy supports it. Quantum Control does +not blindly repeat the failed mutation. + +See `docs/SERVICE-MUTATIONS.md`. ## Durable audit -Audit records use `quantum.control/audit-record/v1alpha1` and are written as append-only JSON Lines. +Audit records use `quantum.control/audit-record/v1alpha1` and are written as +append-only JSON Lines. -Each record contains sequence metadata and a SHA-256 hash of the canonical record plus the previous entry hash. Startup verifies the complete chain. Truncation, reordering, editing or malformed records cause the audit store to fail closed instead of silently repairing history. +Each record contains sequence metadata and a SHA-256 hash of the canonical +record plus the previous entry hash. Startup verifies the complete chain. +Truncation, reordering, editing or malformed records cause the audit store to +fail closed instead of silently repairing history. -Audit records distinguish human, service and TCI actors and include stable request/session/plan correlation where available. +Mutation flows distinguish proposal/plan creation, human approval, execution +attempt and final result. Recovery state is recorded through the +`rollback_status` field. -Secrets are redacted by parameter name. Raw bearer tokens, confirmation tokens, passwords, credentials, API keys and private keys must never be stored. Operational failures are recorded by stable error code rather than arbitrary raw exception text. +Secrets are redacted by parameter name. Raw bearer tokens, confirmation tokens, +passwords, credentials, API keys and private keys must never be stored. +Operational failures are recorded by stable error code rather than arbitrary raw +exception text. See `docs/AUDIT.md` for retention, export and recovery rules. ## Current controls - public API defaults to `127.0.0.1:17440` -- every non-loopback bind requires either the legacy strong API token or an actor registry -- broker authentication is independent from public actor authentication +- every non-loopback bind requires a credential source +- broker authentication is independent from actor authentication - broker listens only on a protected Unix socket -- public actor fields supplied in request JSON are overwritten by authenticated identity -- optional `X-Quantum-Session-ID` is syntax-validated and otherwise generated from request correlation +- public actor fields supplied in JSON are overwritten by authenticated identity +- optional `X-Quantum-Session-ID` is syntax-validated and bound into plans - only registered typed operations can execute -- current broker operations are still read-only -- component inventory uses fixed probes only -- confirmation-required broker execution is fail-closed until a verifier is connected -- security state lives under the protected systemd `StateDirectory=quantum-control` +- read-only inventory uses fixed probes only +- confirmation grants live in root-owned broker state +- approved service mutations require broker-side revalidation and grant consumption +- mutation unit policy currently contains only `quantum-runtime.service` +- `quantum-control.service`, Ollama, Apache, databases and arbitrary systemd units remain outside the mutation surface - request bodies and broker responses are bounded -- public transport errors do not reveal internal broker paths or credentials +- public transport errors do not reveal broker credentials +- ambiguous post-submission mutation outcomes are not automatically retried +- generic shell execution remains unsupported -## Mutation gate +## Mutation gate for future actions -No write operation may enter the registry until all of the following exist: +Every additional write operation must independently satisfy all of these +requirements before entering the broker registry: 1. explicit typed action and bounded parameter schema 2. authenticated actor and permission policy -3. immutable plan output describing the exact intended change +3. immutable plan describing the exact intended change 4. risk classification and explicit confirmation policy -5. broker-side verification of a single-use structured confirmation grant when required +5. broker-side verification of a single-use structured grant when required 6. precondition checks 7. deterministic adapter execution without a shell 8. postcondition health verification @@ -156,6 +248,9 @@ No write operation may enter the registry until all of the following exist: 10. rollback or a documented non-rollbackable boundary 11. dedicated rejection, replay, interruption and partial-failure tests +Passing this gate for Runtime service lifecycle control does not automatically +authorize another service or another class of administrative write. + A generic `shell.exec` action is a permanent non-goal. ## Threat model summary @@ -166,21 +261,36 @@ The security contracts explicitly address: - forged actor fields in API payloads - stolen or replayed confirmation tokens - parameter changes after human review -- TCI self-approval +- stale plans after policy changes +- TCI self-approval or mutation attempts +- arbitrary systemd unit targeting +- blind retry after ambiguous transport failure - audit modification after execution - accidental ownership claims over externally managed services - unsafe remote exposure without authentication -They do not make a compromised root account trustworthy. Root can replace binaries, configuration and state by definition. Quantum Control protects the boundary between ordinary clients/AI and privileged system administration. +They do not make a compromised root account trustworthy. Root can replace +binaries, configuration and state by definition. Quantum Control protects the +boundary between ordinary clients/AI and privileged system administration. ## Recovery Corrupt actor, grant or audit state is not silently reset. -If audit-chain verification fails, Quantum Control refuses to initialize the durable security layer. An operator must preserve the damaged audit file for investigation and restore a known-good copy or intentionally start a new audit lineage under an explicit future recovery procedure. Automatic truncation is forbidden. +If audit-chain verification fails, Quantum Control refuses to initialize the +durable security layer. An operator must preserve the damaged audit file for +investigation and restore a known-good copy or intentionally begin a new lineage +through a future explicit recovery procedure. Automatic truncation is forbidden. + +Loss of confirmation-grant state invalidates outstanding approvals. It never +causes them to become valid again. -Loss of confirmation-grant state invalidates outstanding approvals. It never causes them to become valid again. +For an ambiguous mutation result, inspect current service state and audit data. +If another change is required, create a new plan and obtain a new approval. Do +not replay the original confirmation token. ## Reporting -Do not publish live credentials or exploitable private deployment details in a public issue. Use the official Starlight Unit Studios contact channel for sensitive reports until a dedicated security address is documented. +Do not publish live credentials or exploitable private deployment details in a +public issue. Use the official Starlight Unit Studios contact channel for +sensitive reports until a dedicated security address is documented. diff --git a/docs/SERVICE-MUTATIONS.md b/docs/SERVICE-MUTATIONS.md new file mode 100644 index 0000000..4ddfe5f --- /dev/null +++ b/docs/SERVICE-MUTATIONS.md @@ -0,0 +1,157 @@ +# Transactional service mutations + +Quantum Control `0.3.0-alpha.1` introduces the first privileged mutation path. +The implementation is deliberately limited to systemd lifecycle control for a +compile-time set of supported services. + +## Initial action set + +The broker publishes three confirmation-required operations: + +```text +service.start +service.stop +service.restart +``` + +The compiled mutation allowlist currently contains only: + +```text +quantum-runtime.service +``` + +A deployment policy may remove that unit from the enabled surface. It cannot +add another unit, even when the new name is syntactically valid. + +## Trust boundary + +The public `quantum-control` process remains unprivileged. It authenticates the +caller, requests a typed broker plan and creates the immutable public operation +plan. Confirmation-grant state is not stored by this process. + +`qcored` owns the durable grant store and independently authenticates the human +approver and later the mutation executor. The broker does not trust a boolean, +header or free-form confirmation string supplied by the public process. + +The execution sequence is: + +```text +proposal + -> broker policy plan + -> immutable public plan + SHA-256 digest + -> distinct authenticated human approval + -> qcored creates one single-use grant + -> authenticated mutation executor submits exact plan + grant + -> qcored revalidates plan and current policy + -> qcored atomically consumes grant + -> fixed systemctl argument vector + -> bounded postcondition and health verification + -> result + recovery status + -> durable public audit +``` + +## Grant binding + +A grant is bound to the immutable plan ID and digest, plan actor, session ID and +exact action. The plan digest already binds normalized parameters, risk, +validity and expiry. Any changed action, actor, session or parameter produces a +different plan and cannot reuse the original grant. + +The grant token itself is random and is never persisted in plaintext. The +root-owned grant store keeps only its SHA-256 digest and durable consumed state. +Consumption happens before the privileged adapter is called. Therefore a +success, failure, lost HTTP response or process restart cannot make the same +grant valid for another execution attempt. + +## Executor and approver separation + +The `approver` role is human-only and can issue confirmation for an eligible +plan. It does not automatically receive `operations.execute.mutate`. + +The `mutator` role is intended for an authenticated service identity that may +submit an already approved plan. It cannot issue its own confirmation unless it +also represents a separate eligible human identity, which the current actor +model does not permit. + +TCI actors cannot hold either authority. Model output remains proposal data. + +## Privileged adapter + +The service adapter never accepts a command line. Production constructs exactly +one of these argument vectors: + +```text +systemctl start -- quantum-runtime.service +systemctl stop -- quantum-runtime.service +systemctl restart -- quantum-runtime.service +``` + +The executable and verb are selected by code. The unit must first pass both the +unit-name validator and the compiled/deployment allowlist. No shell is involved. + +## Precondition and postcondition + +Before executing an action, `qcored` captures bounded service state through the +existing fixed `systemctl show` probe. + +After the mutation it polls until the expected state is observed or the hard +transaction timeout expires: + +```text +start -> active +restart -> active +stop -> inactive +``` + +For an active `quantum-runtime.service` postcondition, the current profile also +requires HTTP 200 from the fixed loopback Runtime health endpoint: + +```text +http://127.0.0.1:11450/healthz +``` + +The health URL is compile-time policy data, not caller input. Redirects and +proxy use are disabled. + +## Recovery behavior + +Recovery is intentionally narrow and deterministic. Quantum Control does not +blindly repeat the failed mutation. + +If the observed precondition was `active`, a failed transaction may attempt one +`service.start` recovery toward an active state. If the precondition was +`inactive` or `failed`, recovery may attempt one `service.stop` toward an +inactive state. For another or ambiguous precondition, recovery is +`not_defined` and no speculative action is chosen. + +A successful restart cannot restore the previous process instance. Recovery +means restoring the expected running/stopped service state, not rolling a +process back in time. + +The result exposes a bounded `recovery_status` such as: + +```text +not_required +succeeded +failed +not_defined +``` + +The public durable audit copies this into `rollback_status` for compatibility +with the broader transactional-operation contract. + +## Interrupted and ambiguous execution + +A public-to-broker transport error after an approved execution request has been +submitted is treated as an unknown result by the public service. It is not +retried automatically. The durable grant has already been consumed before the +system adapter runs, so replaying the same token is rejected. + +Operators must inspect current service state and audit evidence, then create a +new plan and obtain a new human confirmation if another mutation is required. + +## Explicit non-goals for this milestone + +This release does not permit mutation of `quantum-control.service`, Ollama, +Apache/Nginx, databases, PHP, containers or arbitrary systemd units. It does not +manage domains, TLS, packages or files and does not add generic shell access. diff --git a/internal/broker/approval.go b/internal/broker/approval.go new file mode 100644 index 0000000..c4fa7eb --- /dev/null +++ b/internal/broker/approval.go @@ -0,0 +1,53 @@ +package broker + +import ( + "context" + "errors" + "strings" + + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/protocol" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/security" +) + +const maxForwardedActorTokenBytes = 4096 + +// ApprovalAPI is the mutation extension implemented by the real broker client. +// Keeping it separate from API preserves the existing read-only broker contract +// for integrations and test doubles. +type ApprovalAPI interface { + Confirm(context.Context, security.OperationPlan, string) (security.GrantResponse, error) + ExecuteApproved(context.Context, security.OperationPlan, string, string) (protocol.OperationResponse, error) +} + +type confirmationEnvelope struct { + Plan security.OperationPlan `json:"plan"` + ActorToken string `json:"actor_token"` +} + +type approvedExecutionEnvelope struct { + Plan security.OperationPlan `json:"plan"` + ConfirmationToken string `json:"confirmation_token"` + ActorToken string `json:"actor_token"` +} + +// SecurityBoundary is root-owned qcored state. The unprivileged public service +// cannot create or consume grants directly. +type SecurityBoundary struct { + Actors security.Authenticator + Grants *security.GrantStore +} + +func (b SecurityBoundary) authenticateActor(token string) (security.Actor, error) { + if b.Actors == nil { + return security.Actor{}, errors.New("broker actor authentication is not configured") + } + token = strings.TrimSpace(token) + if token == "" || len(token) > maxForwardedActorTokenBytes { + return security.Actor{}, errors.New("valid actor credential required") + } + actor, ok := b.Actors.AuthenticateBearer(token) + if !ok { + return security.Actor{}, errors.New("valid actor credential required") + } + return actor, nil +} diff --git a/internal/broker/client.go b/internal/broker/client.go index 01da706..4d1af58 100644 --- a/internal/broker/client.go +++ b/internal/broker/client.go @@ -11,11 +11,11 @@ import ( "time" "github.com/Starlight-Unit-Studio/Quantum-Control/internal/protocol" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/security" ) const maxBrokerResponseBytes = int64(4 << 20) -// API is the broker contract consumed by the unprivileged Control process. type API interface { Health(context.Context) error Catalog(context.Context) ([]protocol.OperationDefinition, error) @@ -23,7 +23,6 @@ type API interface { Execute(context.Context, protocol.OperationRequest) (protocol.OperationResponse, error) } -// Client talks to qcored over a Unix domain socket. type Client struct { httpClient *http.Client token string @@ -37,10 +36,7 @@ func NewClient(socketPath, token string, timeout time.Duration) *Client { return dialer.DialContext(ctx, "unix", socketPath) }, } - return &Client{ - httpClient: &http.Client{Transport: transport, Timeout: timeout}, - token: token, - } + return &Client{httpClient: &http.Client{Transport: transport, Timeout: timeout}, token: token} } func (c *Client) Health(ctx context.Context) error { @@ -66,29 +62,23 @@ func (c *Client) Plan(ctx context.Context, request protocol.OperationRequest) (p func (c *Client) Execute(ctx context.Context, request protocol.OperationRequest) (protocol.OperationResponse, error) { var response protocol.OperationResponse - err := c.do( - ctx, - http.MethodPost, - "/v1/execute", - request, - &response, - true, - http.StatusOK, - http.StatusBadRequest, - http.StatusInternalServerError, - ) + err := c.do(ctx, http.MethodPost, "/v1/execute", request, &response, true, http.StatusOK, http.StatusBadRequest, http.StatusInternalServerError) return response, err } -func (c *Client) do( - ctx context.Context, - method string, - path string, - input any, - output any, - authenticated bool, - acceptedStatuses ...int, -) error { +func (c *Client) Confirm(ctx context.Context, plan security.OperationPlan, actorToken string) (security.GrantResponse, error) { + var response security.GrantResponse + err := c.do(ctx, http.MethodPost, "/v1/confirm", confirmationEnvelope{Plan: plan, ActorToken: actorToken}, &response, true, http.StatusCreated) + return response, err +} + +func (c *Client) ExecuteApproved(ctx context.Context, plan security.OperationPlan, confirmationToken string, actorToken string) (protocol.OperationResponse, error) { + var response protocol.OperationResponse + err := c.do(ctx, http.MethodPost, "/v1/execute-approved", approvedExecutionEnvelope{Plan: plan, ConfirmationToken: confirmationToken, ActorToken: actorToken}, &response, true, http.StatusOK, http.StatusBadRequest, http.StatusInternalServerError) + return response, err +} + +func (c *Client) do(ctx context.Context, method string, path string, input any, output any, authenticated bool, acceptedStatuses ...int) error { var body io.Reader if input != nil { encoded, err := json.Marshal(input) @@ -112,7 +102,6 @@ func (c *Client) do( return fmt.Errorf("broker request: %w", err) } defer resp.Body.Close() - data, err := io.ReadAll(io.LimitReader(resp.Body, maxBrokerResponseBytes+1)) if err != nil { return fmt.Errorf("read broker response: %w", err) diff --git a/internal/broker/mutations.go b/internal/broker/mutations.go new file mode 100644 index 0000000..1042441 --- /dev/null +++ b/internal/broker/mutations.go @@ -0,0 +1,334 @@ +package broker + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/protocol" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/security" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/servicecontrol" +) + +type serviceMutator interface { + Start(context.Context, string) error + Stop(context.Context, string) error + Restart(context.Context, string) error +} + +type healthChecker interface { + Check(context.Context, string) error +} + +type serviceTarget struct { + healthURL string +} + +type operationError struct { + code string + message string +} + +func (e operationError) Error() string { return e.message } + +func newOperationError(code, message string) error { + return operationError{code: code, message: message} +} + +func problemFromOperationError(err error) *protocol.Problem { + var typed operationError + if errors.As(err, &typed) { + return &protocol.Problem{Code: typed.code, Message: typed.message} + } + return &protocol.Problem{Code: "operation_failed", Message: err.Error()} +} + +// EnableServiceMutations adds the first privileged operation set. The supplied +// deployment policy can only narrow the compile-time service allowlist. +func (r *Registry) EnableServiceMutations( + mutator serviceMutator, + health healthChecker, + policy servicecontrol.Policy, + transactionTimeout time.Duration, + pollInterval time.Duration, +) error { + if mutator == nil { + return errors.New("service mutator is required") + } + if health == nil { + return errors.New("service health checker is required") + } + if transactionTimeout < time.Second || transactionTimeout > 2*time.Minute { + return errors.New("service transaction timeout must be between 1 second and 2 minutes") + } + if pollInterval < 10*time.Millisecond || pollInterval > 5*time.Second { + return errors.New("service poll interval must be between 10 milliseconds and 5 seconds") + } + + r.mutator = mutator + r.health = health + r.transactionTimeout = transactionTimeout + r.pollInterval = pollInterval + r.serviceTargets = make(map[string]serviceTarget) + units := policy.AllowedUnits() + for _, unit := range units { + spec, ok := policy.Spec(unit) + if !ok { + return fmt.Errorf("service policy lost target %q", unit) + } + r.serviceTargets[unit] = serviceTarget{healthURL: spec.HealthURL} + } + + register := func(action, summary string, risk protocol.Risk) { + r.register(protocol.OperationDefinition{ + Action: action, + Summary: summary, + Risk: risk, + RequiresConfirmation: true, + Implemented: true, + Parameters: []protocol.ParameterDefinition{{ + Name: "unit", + Description: "Exact service unit from the compiled mutation allowlist", + Required: true, + AllowedValues: append([]string{}, units...), + }}, + }, func(ctx context.Context, request protocol.OperationRequest) (map[string]any, error) { + return r.runServiceTransaction(ctx, action, request.Parameters["unit"]) + }) + } + register("service.start", "Start an allowlisted managed service", protocol.RiskLow) + register("service.stop", "Stop an allowlisted managed service", protocol.RiskHigh) + register("service.restart", "Restart an allowlisted managed service", protocol.RiskLow) + return nil +} + +// ValidateApprovedPlan replays qcored policy over the immutable public plan. +// A valid digest alone is insufficient: action metadata and the current service +// policy must still match the broker catalog at execution time. +func (r *Registry) ValidateApprovedPlan(plan security.OperationPlan) *protocol.Problem { + now := r.now().UTC() + if plan.Schema != security.PlanSchema || !plan.Valid || !plan.RequiresConfirmation { + return &protocol.Problem{Code: "invalid_plan", Message: "operation plan is not executable"} + } + if !security.VerifyPlanDigest(plan) { + return &protocol.Problem{Code: "invalid_plan_digest", Message: "operation plan digest verification failed"} + } + if strings.TrimSpace(plan.ID) == "" || strings.TrimSpace(plan.Actor.ID) == "" || strings.TrimSpace(plan.SessionID) == "" { + return &protocol.Problem{Code: "invalid_plan", Message: "operation plan identity or correlation data is missing"} + } + if plan.CreatedAt.IsZero() || plan.ExpiresAt.IsZero() || !plan.ExpiresAt.After(plan.CreatedAt) { + return &protocol.Problem{Code: "invalid_plan_time", Message: "operation plan time bounds are invalid"} + } + if plan.CreatedAt.After(now.Add(30*time.Second)) || !plan.ExpiresAt.After(now) { + return &protocol.Problem{Code: "expired_plan", Message: "operation plan is stale or expired"} + } + if plan.ExpiresAt.Sub(plan.CreatedAt) > 15*time.Minute { + return &protocol.Problem{Code: "invalid_plan_time", Message: "operation plan lifetime exceeds policy"} + } + + parameters, problem := exactPlanParameters(plan) + if problem != nil { + return problem + } + request := protocol.OperationRequest{ + RequestID: plan.RequestID, + Action: plan.Action, + Actor: plan.Actor.ID, + Parameters: parameters, + } + operation, problem := r.validate(request, false) + if problem != nil { + return problem + } + if !operation.definition.RequiresConfirmation || string(operation.definition.Risk) != plan.Risk { + return &protocol.Problem{Code: "stale_plan_policy", Message: "operation policy changed after the plan was created"} + } + return nil +} + +func exactPlanParameters(plan security.OperationPlan) (map[string]string, *protocol.Problem) { + result := make(map[string]string, len(plan.Parameters)) + for _, parameter := range plan.Parameters { + name := strings.TrimSpace(parameter.Name) + if name == "" { + return nil, &protocol.Problem{Code: "invalid_plan", Message: "operation plan contains an empty parameter name"} + } + if _, exists := result[name]; exists { + return nil, &protocol.Problem{Code: "invalid_plan", Message: "operation plan contains duplicate parameters"} + } + result[name] = parameter.Value + } + return result, nil +} + +// ExecuteApproved runs a plan only after the server has independently +// authenticated an executor and atomically consumed the matching grant. +func (r *Registry) ExecuteApproved(ctx context.Context, plan security.OperationPlan, executor security.Actor) protocol.OperationResponse { + started := time.Now().UTC() + response := protocol.OperationResponse{ + RequestID: plan.RequestID, + Action: plan.Action, + Status: "rejected", + StartedAt: started, + AuditID: newID("audit"), + } + if response.RequestID == "" { + response.RequestID = newID("request") + } + if executor.Kind == security.ActorTCI || !security.HasPermission(executor, security.PermissionOperationMutate) { + response.Error = &protocol.Problem{Code: "mutation_forbidden", Message: "executor is not authorized for service mutations"} + response.FinishedAt = time.Now().UTC() + return response + } + if problem := r.ValidateApprovedPlan(plan); problem != nil { + response.Error = problem + response.FinishedAt = time.Now().UTC() + return response + } + + parameters, _ := exactPlanParameters(plan) + request := protocol.OperationRequest{ + RequestID: plan.RequestID, + Action: plan.Action, + Actor: plan.Actor.ID, + Parameters: parameters, + } + operation := r.operations[plan.Action] + response.Risk = operation.definition.Risk + result, err := operation.handler(ctx, request) + if result == nil { + result = map[string]any{} + } + result["executor_actor_id"] = executor.ID + result["plan_actor_id"] = plan.Actor.ID + result["plan_id"] = plan.ID + result["plan_digest"] = plan.Digest + response.Result = result + response.FinishedAt = time.Now().UTC() + if err != nil { + response.Status = "failed" + response.Error = problemFromOperationError(err) + return response + } + response.Status = "completed" + return response +} + +func (r *Registry) runServiceTransaction(ctx context.Context, action, unit string) (map[string]any, error) { + target, ok := r.serviceTargets[unit] + if !ok { + return nil, newOperationError("service_not_allowlisted", "service is not in the mutation allowlist") + } + txCtx, cancel := context.WithTimeout(ctx, r.transactionTimeout) + defer cancel() + + precondition, err := r.probe.ServiceStatus(txCtx, unit) + if err != nil { + return nil, newOperationError("service_precondition_failed", "service precondition could not be observed") + } + preActive := stateValue(precondition, "active_state") + result := map[string]any{ + "action": action, + "unit": unit, + "precondition": precondition, + "recovery_status": "not_required", + } + if target.healthURL != "" && preActive == "active" { + if err := r.health.Check(txCtx, target.healthURL); err == nil { + result["pre_health"] = "healthy" + } else { + result["pre_health"] = "unhealthy" + } + } + + if err := r.applyServiceAction(txCtx, action, unit); err != nil { + postcondition, _ := r.probe.ServiceStatus(txCtx, unit) + result["postcondition"] = postcondition + result["recovery_status"] = r.recoverPrecondition(txCtx, unit, preActive) + return result, newOperationError("service_action_failed", "service action failed before the expected postcondition was reached") + } + + expected := expectedActiveState(action) + postcondition, err := r.waitForServicePostcondition(txCtx, unit, target.healthURL, expected) + result["postcondition"] = postcondition + if err != nil { + result["recovery_status"] = r.recoverPrecondition(txCtx, unit, preActive) + return result, newOperationError("service_postcondition_failed", "service did not reach the required postcondition before timeout") + } + result["postcondition_verified"] = true + if expected == "active" && target.healthURL != "" { + result["health_verified"] = true + } + return result, nil +} + +func (r *Registry) applyServiceAction(ctx context.Context, action, unit string) error { + switch action { + case "service.start": + return r.mutator.Start(ctx, unit) + case "service.stop": + return r.mutator.Stop(ctx, unit) + case "service.restart": + return r.mutator.Restart(ctx, unit) + default: + return errors.New("unsupported service mutation") + } +} + +func expectedActiveState(action string) string { + if action == "service.stop" { + return "inactive" + } + return "active" +} + +func (r *Registry) waitForServicePostcondition(ctx context.Context, unit, healthURL, expected string) (map[string]any, error) { + ticker := time.NewTicker(r.pollInterval) + defer ticker.Stop() + var last map[string]any + for { + state, err := r.probe.ServiceStatus(ctx, unit) + if err == nil { + last = state + if stateValue(state, "active_state") == expected { + if expected != "active" || healthURL == "" || r.health.Check(ctx, healthURL) == nil { + return state, nil + } + } + } + select { + case <-ctx.Done(): + return last, ctx.Err() + case <-ticker.C: + } + } +} + +func (r *Registry) recoverPrecondition(ctx context.Context, unit, preActive string) string { + if ctx.Err() != nil { + return "failed" + } + var action string + switch preActive { + case "active": + action = "service.start" + case "inactive", "failed": + action = "service.stop" + default: + return "not_defined" + } + if err := r.applyServiceAction(ctx, action, unit); err != nil { + return "failed" + } + if _, err := r.waitForServicePostcondition(ctx, unit, "", expectedActiveState(action)); err != nil { + return "failed" + } + return "succeeded" +} + +func stateValue(state map[string]any, key string) string { + value, _ := state[key].(string) + return strings.ToLower(strings.TrimSpace(value)) +} diff --git a/internal/broker/mutations_test.go b/internal/broker/mutations_test.go new file mode 100644 index 0000000..fcb1242 --- /dev/null +++ b/internal/broker/mutations_test.go @@ -0,0 +1,338 @@ +package broker + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/protocol" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/security" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/servicecontrol" +) + +const ( + brokerTestToken = "broker-token-012345678901234567890123456789" + mutatorTestToken = "mutator-token-01234567890123456789012345678" + approverTestToken = "approver-token-0123456789012345678901234567" + tciBrokerToken = "tci-token-012345678901234567890123456789012" +) + +type mutableProbe struct { + mu sync.Mutex + state string +} + +func (p *mutableProbe) Snapshot(context.Context) (map[string]any, error) { + return map[string]any{"hostname": "mutation-test"}, nil +} + +func (p *mutableProbe) ServiceStatus(_ context.Context, unit string) (map[string]any, error) { + p.mu.Lock() + defer p.mu.Unlock() + return map[string]any{ + "unit": unit, + "load_state": "loaded", + "active_state": p.state, + "sub_state": map[string]string{"active": "running", "inactive": "dead"}[p.state], + }, nil +} + +func (p *mutableProbe) set(state string) { + p.mu.Lock() + p.state = state + p.mu.Unlock() +} + +type fakeServiceMutator struct { + probe *mutableProbe + calls []string + failRestart bool +} + +func (m *fakeServiceMutator) Start(_ context.Context, unit string) error { + m.calls = append(m.calls, "start "+unit) + m.probe.set("active") + return nil +} + +func (m *fakeServiceMutator) Stop(_ context.Context, unit string) error { + m.calls = append(m.calls, "stop "+unit) + m.probe.set("inactive") + return nil +} + +func (m *fakeServiceMutator) Restart(_ context.Context, unit string) error { + m.calls = append(m.calls, "restart "+unit) + if m.failRestart { + m.failRestart = false + return errors.New("injected restart failure") + } + m.probe.set("active") + return nil +} + +type healthyService struct{} + +func (healthyService) Check(context.Context, string) error { return nil } + +func TestMutationCatalogOnlyAllowsCompiledQuantumRuntimeUnit(t *testing.T) { + probe := &mutableProbe{state: "active"} + registry := NewRegistry(probe) + if err := registry.EnableServiceMutations(&fakeServiceMutator{probe: probe}, healthyService{}, servicecontrol.DefaultPolicy(), time.Second, 10*time.Millisecond); err != nil { + t.Fatal(err) + } + + plan := registry.Plan(protocol.OperationRequest{ + Action: "service.restart", + Parameters: map[string]string{"unit": "apache2.service"}, + }) + if plan.Valid || plan.Error == nil || plan.Error.Code != "invalid_parameters" { + t.Fatalf("arbitrary syntactically valid unit was accepted: %#v", plan) + } + + plan = registry.Plan(protocol.OperationRequest{ + Action: "service.restart", + Parameters: map[string]string{"unit": "quantum-runtime.service"}, + }) + if !plan.Valid || !plan.RequiresConfirmation || plan.Definition.Risk != protocol.RiskLow { + t.Fatalf("runtime restart plan policy mismatch: %#v", plan) + } + + response := registry.Execute(context.Background(), protocol.OperationRequest{ + Action: "service.restart", + Parameters: map[string]string{"unit": "quantum-runtime.service"}, + Confirmation: "caller-controlled-string", + }) + if response.Status != "rejected" || response.Error == nil || response.Error.Code != "confirmation_verifier_required" { + t.Fatalf("legacy execution bypassed structured confirmation gate: %#v", response) + } +} + +func TestBrokerApprovedRestartConsumesGrantAndRejectsReplay(t *testing.T) { + server, registry, boundary, mutator, actors := newMutationBrokerServer(t, false) + defer server.Close() + + plan := buildMutationPlan(t, registry, actors.mutator, "session-one", "service.restart", "quantum-runtime.service") + grant := confirmPlan(t, server.URL, plan, approverTestToken) + response := executeApproved(t, server.URL, plan, grant.Token, mutatorTestToken) + if response.Status != "completed" || len(mutator.calls) != 1 || mutator.calls[0] != "restart quantum-runtime.service" { + t.Fatalf("approved restart failed: response=%#v calls=%#v", response, mutator.calls) + } + if response.Result["health_verified"] != true || response.Result["recovery_status"] != "not_required" { + t.Fatalf("postcondition metadata missing: %#v", response.Result) + } + + replay := executeApproved(t, server.URL, plan, grant.Token, mutatorTestToken) + if replay.Status != "rejected" || replay.Error == nil || replay.Error.Code != "confirmation_rejected" { + t.Fatalf("grant replay succeeded: %#v", replay) + } + + if _, err := security.OpenGrantStore(boundary.path, 2*time.Minute); err != nil { + t.Fatalf("durable consumed grant could not be reopened: %v", err) + } +} + +func TestApprovedMutationBindsSessionActorActionAndParameters(t *testing.T) { + server, registry, _, _, actors := newMutationBrokerServer(t, false) + defer server.Close() + + plan := buildMutationPlan(t, registry, actors.mutator, "session-bind", "service.restart", "quantum-runtime.service") + grant := confirmPlan(t, server.URL, plan, approverTestToken) + + changedSession := plan + changedSession.SessionID = "session-other" + changedSession.Digest = mustPlanDigest(t, changedSession) + response := executeApproved(t, server.URL, changedSession, grant.Token, mutatorTestToken) + if response.Status != "rejected" || response.Error == nil || response.Error.Code != "confirmation_rejected" { + t.Fatalf("session-modified plan crossed grant boundary: %#v", response) + } + + changedActor := plan + changedActor.Actor = actors.tci + changedActor.Digest = mustPlanDigest(t, changedActor) + response = executeApproved(t, server.URL, changedActor, grant.Token, mutatorTestToken) + if response.Status != "rejected" || response.Error == nil || response.Error.Code != "confirmation_rejected" { + t.Fatalf("actor-modified plan crossed grant boundary: %#v", response) + } + + changedParameters := plan + changedParameters.Parameters = []security.Parameter{{Name: "unit", Value: "apache2.service"}} + changedParameters.Digest = mustPlanDigest(t, changedParameters) + response = executeApproved(t, server.URL, changedParameters, grant.Token, mutatorTestToken) + if response.Status != "rejected" || response.Error == nil || response.Error.Code != "invalid_parameters" { + t.Fatalf("parameter-modified plan crossed broker policy: %#v", response) + } + + changedAction := plan + changedAction.Action = "service.start" + changedAction.Digest = mustPlanDigest(t, changedAction) + response = executeApproved(t, server.URL, changedAction, grant.Token, mutatorTestToken) + if response.Status != "rejected" || response.Error == nil || response.Error.Code != "confirmation_rejected" { + t.Fatalf("action-modified plan crossed grant boundary: %#v", response) + } + + response = executeApproved(t, server.URL, plan, grant.Token, tciBrokerToken) + if response.Status != "rejected" || response.Error == nil || response.Error.Code != "mutation_forbidden" { + t.Fatalf("TCI executed approved mutation: %#v", response) + } + + response = executeApproved(t, server.URL, plan, grant.Token, mutatorTestToken) + if response.Status != "completed" { + t.Fatalf("valid original plan/grant did not survive rejected tamper attempts: %#v", response) + } +} + +func TestExpiredPlanFailsBeforeGrantConsumption(t *testing.T) { + server, registry, _, _, actors := newMutationBrokerServer(t, false) + defer server.Close() + plan := buildMutationPlan(t, registry, actors.mutator, "session-expire", "service.restart", "quantum-runtime.service") + grant := confirmPlan(t, server.URL, plan, approverTestToken) + + expired := plan + expired.CreatedAt = time.Now().Add(-10 * time.Minute).UTC() + expired.ExpiresAt = time.Now().Add(-5 * time.Minute).UTC() + expired.Digest = mustPlanDigest(t, expired) + response := executeApproved(t, server.URL, expired, grant.Token, mutatorTestToken) + if response.Status != "rejected" || response.Error == nil || response.Error.Code != "expired_plan" { + t.Fatalf("expired plan was not rejected: %#v", response) + } + + response = executeApproved(t, server.URL, plan, grant.Token, mutatorTestToken) + if response.Status != "completed" { + t.Fatalf("expired tamper attempt consumed valid grant: %#v", response) + } +} + +func TestFailedRestartAttemptsDefinedRecoveryOnce(t *testing.T) { + server, registry, _, mutator, actors := newMutationBrokerServer(t, true) + defer server.Close() + plan := buildMutationPlan(t, registry, actors.mutator, "session-recover", "service.restart", "quantum-runtime.service") + grant := confirmPlan(t, server.URL, plan, approverTestToken) + response := executeApproved(t, server.URL, plan, grant.Token, mutatorTestToken) + if response.Status != "failed" || response.Error == nil || response.Error.Code != "service_action_failed" { + t.Fatalf("injected failure was not reported: %#v", response) + } + if response.Result["recovery_status"] != "succeeded" { + t.Fatalf("recovery was not recorded: %#v", response.Result) + } + if len(mutator.calls) != 2 || mutator.calls[0] != "restart quantum-runtime.service" || mutator.calls[1] != "start quantum-runtime.service" { + t.Fatalf("unexpected retry/recovery behavior: %#v", mutator.calls) + } +} + +type mutationActors struct { + mutator security.Actor + approver security.Actor + tci security.Actor +} + +type testGrantBoundary struct { + path string +} + +func newMutationBrokerServer(t *testing.T, failRestart bool) (*httptest.Server, *Registry, testGrantBoundary, *fakeServiceMutator, mutationActors) { + t.Helper() + probe := &mutableProbe{state: "active"} + mutator := &fakeServiceMutator{probe: probe, failRestart: failRestart} + registry := NewRegistry(probe) + if err := registry.EnableServiceMutations(mutator, healthyService{}, servicecontrol.DefaultPolicy(), time.Second, 10*time.Millisecond); err != nil { + t.Fatal(err) + } + + mutatorAuth, mutatorActor := testAuthenticator(t, security.Actor{ID: "service:mutation-executor", Kind: security.ActorService, DisplayName: "Mutation executor", Roles: []string{"mutator"}}, mutatorTestToken) + approverAuth, approverActor := testAuthenticator(t, security.Actor{ID: "human:rick", Kind: security.ActorHuman, DisplayName: "Rick", Roles: []string{"approver"}}, approverTestToken) + tciAuth, tciActor := testAuthenticator(t, security.Actor{ID: "tci:quantum", Kind: security.ActorTCI, DisplayName: "Quantum TCI", Roles: []string{"tci-proposer"}}, tciBrokerToken) + grantPath := filepath.Join(t.TempDir(), "grants.json") + grants, err := security.OpenGrantStore(grantPath, 2*time.Minute) + if err != nil { + t.Fatal(err) + } + boundary := SecurityBoundary{Actors: security.MultiAuthenticator{mutatorAuth, approverAuth, tciAuth}, Grants: grants} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := httptest.NewServer(NewServerWithSecurity(registry, brokerTestToken, 1<<20, logger, boundary).Handler()) + return server, registry, testGrantBoundary{path: grantPath}, mutator, mutationActors{mutator: mutatorActor, approver: approverActor, tci: tciActor} +} + +func testAuthenticator(t *testing.T, actor security.Actor, token string) (security.Authenticator, security.Actor) { + t.Helper() + auth, err := security.NewStaticAuthenticator(actor, token) + if err != nil { + t.Fatal(err) + } + normalized, ok := auth.AuthenticateBearer(token) + if !ok { + t.Fatal("test actor authentication failed") + } + return auth, normalized +} + +func buildMutationPlan(t *testing.T, registry *Registry, actor security.Actor, session, action, unit string) security.OperationPlan { + t.Helper() + brokerPlan := registry.Plan(protocol.OperationRequest{RequestID: "request-test", Actor: actor.ID, Action: action, Parameters: map[string]string{"unit": unit}}) + if !brokerPlan.Valid { + t.Fatalf("broker plan invalid: %#v", brokerPlan) + } + plan, err := (security.PlanBuilder{TTL: 5 * time.Minute}).Build(actor, session, brokerPlan) + if err != nil { + t.Fatal(err) + } + return plan +} + +func confirmPlan(t *testing.T, baseURL string, plan security.OperationPlan, actorToken string) security.GrantResponse { + t.Helper() + payload, _ := json.Marshal(confirmationEnvelope{Plan: plan, ActorToken: actorToken}) + request, _ := http.NewRequest(http.MethodPost, baseURL+"/v1/confirm", bytes.NewReader(payload)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Quantum-Broker-Token", brokerTestToken) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(response.Body) + t.Fatalf("confirm returned %d: %s", response.StatusCode, body) + } + var grant security.GrantResponse + if err := json.NewDecoder(response.Body).Decode(&grant); err != nil { + t.Fatal(err) + } + return grant +} + +func executeApproved(t *testing.T, baseURL string, plan security.OperationPlan, grantToken, actorToken string) protocol.OperationResponse { + t.Helper() + payload, _ := json.Marshal(approvedExecutionEnvelope{Plan: plan, ConfirmationToken: grantToken, ActorToken: actorToken}) + request, _ := http.NewRequest(http.MethodPost, baseURL+"/v1/execute-approved", bytes.NewReader(payload)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Quantum-Broker-Token", brokerTestToken) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var result protocol.OperationResponse + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + t.Fatal(err) + } + return result +} + +func mustPlanDigest(t *testing.T, plan security.OperationPlan) string { + t.Helper() + digest, err := security.PlanDigest(plan) + if err != nil { + t.Fatal(err) + } + return digest +} diff --git a/internal/broker/registry.go b/internal/broker/registry.go index 91d9ba8..df3a4f3 100644 --- a/internal/broker/registry.go +++ b/internal/broker/registry.go @@ -23,15 +23,24 @@ type registeredOperation struct { handler operationHandler } -// Registry owns the explicit operation allowlist. No request can introduce an -// executable name, argument vector or shell fragment. type Registry struct { - operations map[string]registeredOperation + operations map[string]registeredOperation + probe systemprobe.Probe + mutator serviceMutator + health healthChecker + serviceTargets map[string]serviceTarget + transactionTimeout time.Duration + pollInterval time.Duration + now func() time.Time } -// NewRegistry creates the alpha read-only operation catalog. func NewRegistry(probe systemprobe.Probe) *Registry { - registry := &Registry{operations: make(map[string]registeredOperation)} + registry := &Registry{ + operations: make(map[string]registeredOperation), + probe: probe, + serviceTargets: make(map[string]serviceTarget), + now: time.Now, + } registry.register(protocol.OperationDefinition{ Action: "system.snapshot", Summary: "Read a bounded local system summary", @@ -63,11 +72,15 @@ func (r *Registry) register(definition protocol.OperationDefinition, handler ope r.operations[definition.Action] = registeredOperation{definition: definition, handler: handler} } -// Catalog returns a stable action-sorted copy of the allowlist. func (r *Registry) Catalog() []protocol.OperationDefinition { definitions := make([]protocol.OperationDefinition, 0, len(r.operations)) for _, operation := range r.operations { - definitions = append(definitions, operation.definition) + definition := operation.definition + definition.Parameters = append([]protocol.ParameterDefinition{}, definition.Parameters...) + for index := range definition.Parameters { + definition.Parameters[index].AllowedValues = append([]string{}, definition.Parameters[index].AllowedValues...) + } + definitions = append(definitions, definition) } sort.Slice(definitions, func(i, j int) bool { return definitions[i].Action < definitions[j].Action @@ -75,17 +88,10 @@ func (r *Registry) Catalog() []protocol.OperationDefinition { return definitions } -// Plan validates a request without executing it. A confirmation-required -// operation may be planned without a grant so a human can review the immutable -// plan snapshot before any future execution path is considered. func (r *Registry) Plan(request protocol.OperationRequest) protocol.OperationPlan { operation, problem := r.validate(request, false) if problem != nil { - return protocol.OperationPlan{ - Request: request, - Valid: false, - Error: problem, - } + return protocol.OperationPlan{Request: request, Valid: false, Error: problem} } return protocol.OperationPlan{ Request: request, @@ -95,9 +101,6 @@ func (r *Registry) Plan(request protocol.OperationRequest) protocol.OperationPla } } -// Execute validates and runs one registered operation. Confirmation-required -// operations fail closed until qcored has a verifier for the durable structured -// grant contract. A caller-provided string can never satisfy that boundary. func (r *Registry) Execute(ctx context.Context, request protocol.OperationRequest) protocol.OperationResponse { started := time.Now().UTC() response := protocol.OperationResponse{ @@ -110,7 +113,6 @@ func (r *Registry) Execute(ctx context.Context, request protocol.OperationReques if response.RequestID == "" { response.RequestID = newID("request") } - operation, problem := r.validate(request, true) if problem != nil { response.Error = problem @@ -118,16 +120,15 @@ func (r *Registry) Execute(ctx context.Context, request protocol.OperationReques return response } response.Risk = operation.definition.Risk - result, err := operation.handler(ctx, request) + response.Result = result response.FinishedAt = time.Now().UTC() if err != nil { response.Status = "failed" - response.Error = &protocol.Problem{Code: "operation_failed", Message: err.Error()} + response.Error = problemFromOperationError(err) return response } response.Status = "completed" - response.Result = result return response } @@ -146,19 +147,15 @@ func (r *Registry) validate(request protocol.OperationRequest, execution bool) ( if execution && operation.definition.RequiresConfirmation { return registeredOperation{}, &protocol.Problem{ Code: "confirmation_verifier_required", - Message: "confirmation-required operation execution is disabled until the structured grant verifier is connected", + Message: "confirmation-required operation execution requires the approved execution endpoint", } } - allowed := make(map[string]protocol.ParameterDefinition, len(operation.definition.Parameters)) for _, parameter := range operation.definition.Parameters { allowed[parameter.Name] = parameter value, exists := request.Parameters[parameter.Name] if parameter.Required && (!exists || strings.TrimSpace(value) == "") { - return registeredOperation{}, &protocol.Problem{ - Code: "invalid_parameters", - Message: fmt.Sprintf("parameter %q is required", parameter.Name), - } + return registeredOperation{}, &protocol.Problem{Code: "invalid_parameters", Message: fmt.Sprintf("parameter %q is required", parameter.Name)} } if exists { if problem := validateParameter(parameter, value); problem != nil { @@ -168,10 +165,7 @@ func (r *Registry) validate(request protocol.OperationRequest, execution bool) ( } for name := range request.Parameters { if _, ok := allowed[name]; !ok { - return registeredOperation{}, &protocol.Problem{ - Code: "invalid_parameters", - Message: fmt.Sprintf("parameter %q is not accepted", name), - } + return registeredOperation{}, &protocol.Problem{Code: "invalid_parameters", Message: fmt.Sprintf("parameter %q is not accepted", name)} } } return operation, nil @@ -184,22 +178,16 @@ func validateParameter(definition protocol.ParameterDefinition, value string) *p return &protocol.Problem{Code: "policy_error", Message: "operation parameter policy is invalid"} } if !pattern.MatchString(value) { - return &protocol.Problem{ - Code: "invalid_parameters", - Message: fmt.Sprintf("parameter %q does not match policy", definition.Name), - } + return &protocol.Problem{Code: "invalid_parameters", Message: fmt.Sprintf("parameter %q does not match policy", definition.Name)} } } - if len(definition.AllowedValues) > 0 { + if definition.AllowedValues != nil { for _, allowed := range definition.AllowedValues { if value == allowed { return nil } } - return &protocol.Problem{ - Code: "invalid_parameters", - Message: fmt.Sprintf("parameter %q is not an allowed value", definition.Name), - } + return &protocol.Problem{Code: "invalid_parameters", Message: fmt.Sprintf("parameter %q is not an allowed value", definition.Name)} } return nil } diff --git a/internal/broker/server.go b/internal/broker/server.go index e6342b8..a39d619 100644 --- a/internal/broker/server.go +++ b/internal/broker/server.go @@ -12,22 +12,26 @@ import ( "github.com/Starlight-Unit-Studio/Quantum-Control/internal/buildinfo" "github.com/Starlight-Unit-Studio/Quantum-Control/internal/protocol" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/security" ) -// Server exposes the privileged operation registry over a protected Unix -// socket. The transport does not accept arbitrary commands. type Server struct { registry *Registry token string bodyLimit int64 logger *slog.Logger + security SecurityBoundary } func NewServer(registry *Registry, token string, bodyLimit int64, logger *slog.Logger) *Server { + return NewServerWithSecurity(registry, token, bodyLimit, logger, SecurityBoundary{}) +} + +func NewServerWithSecurity(registry *Registry, token string, bodyLimit int64, logger *slog.Logger, boundary SecurityBoundary) *Server { if logger == nil { logger = slog.Default() } - return &Server{registry: registry, token: token, bodyLimit: bodyLimit, logger: logger} + return &Server{registry: registry, token: token, bodyLimit: bodyLimit, logger: logger, security: boundary} } func (s *Server) Handler() http.Handler { @@ -36,15 +40,13 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /v1/operations", s.requireBrokerAuth(http.HandlerFunc(s.handleCatalog))) mux.Handle("POST /v1/plan", s.requireBrokerAuth(http.HandlerFunc(s.handlePlan))) mux.Handle("POST /v1/execute", s.requireBrokerAuth(http.HandlerFunc(s.handleExecute))) + mux.Handle("POST /v1/confirm", s.requireBrokerAuth(http.HandlerFunc(s.handleConfirm))) + mux.Handle("POST /v1/execute-approved", s.requireBrokerAuth(http.HandlerFunc(s.handleExecuteApproved))) return mux } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "status": "ok", - "service": "qcored", - "version": buildinfo.Version, - }) + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "qcored", "version": buildinfo.Version}) } func (s *Server) handleCatalog(w http.ResponseWriter, _ *http.Request) { @@ -70,39 +72,127 @@ func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) { return } response := s.registry.Execute(r.Context(), request) + s.logOperation(r, response, request.Actor, "") + writeOperationResponse(w, response) +} + +func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { + if s.security.Grants == nil { + writeProblem(w, http.StatusServiceUnavailable, "confirmation_unavailable", "broker confirmation state is unavailable") + return + } + var envelope confirmationEnvelope + if !s.decodeJSON(w, r, &envelope) { + return + } + if problem := s.registry.ValidateApprovedPlan(envelope.Plan); problem != nil { + writeProblem(w, http.StatusBadRequest, problem.Code, problem.Message) + return + } + approver, err := s.security.authenticateActor(envelope.ActorToken) + if err != nil { + writeProblem(w, http.StatusUnauthorized, "approver_unauthorized", "valid human approver credential required") + return + } + if approver.Kind != security.ActorHuman || !security.HasPermission(approver, security.PermissionConfirm) { + writeProblem(w, http.StatusForbidden, "approver_forbidden", "actor may not issue confirmation grants") + return + } + grant, err := s.security.Grants.Issue(envelope.Plan, approver) + if err != nil { + writeProblem(w, http.StatusBadRequest, "confirmation_rejected", "operation plan cannot be confirmed under current policy") + return + } + s.logger.InfoContext(r.Context(), "broker confirmation issued", + "grant_id", grant.Grant.ID, + "plan_id", envelope.Plan.ID, + "plan_digest", envelope.Plan.Digest, + "plan_actor", envelope.Plan.Actor.ID, + "approver", approver.ID, + "action", envelope.Plan.Action, + ) + writeJSON(w, http.StatusCreated, grant) +} + +func (s *Server) handleExecuteApproved(w http.ResponseWriter, r *http.Request) { + var envelope approvedExecutionEnvelope + if !s.decodeJSON(w, r, &envelope) { + return + } + response := rejectedApprovedResponse(envelope.Plan) + if s.security.Grants == nil { + response.Error = &protocol.Problem{Code: "confirmation_unavailable", Message: "broker confirmation state is unavailable"} + response.FinishedAt = time.Now().UTC() + writeOperationResponse(w, response) + return + } + executor, err := s.security.authenticateActor(envelope.ActorToken) + if err != nil || executor.Kind == security.ActorTCI || !security.HasPermission(executor, security.PermissionOperationMutate) { + response.Error = &protocol.Problem{Code: "mutation_forbidden", Message: "executor is not authorized for service mutations"} + response.FinishedAt = time.Now().UTC() + writeOperationResponse(w, response) + return + } + if problem := s.registry.ValidateApprovedPlan(envelope.Plan); problem != nil { + response.Error = problem + response.FinishedAt = time.Now().UTC() + writeOperationResponse(w, response) + return + } + if _, err := s.security.Grants.Consume(envelope.ConfirmationToken, envelope.Plan, envelope.Plan.Actor.ID, envelope.Plan.Action); err != nil { + response.Error = &protocol.Problem{Code: "confirmation_rejected", Message: "confirmation grant is invalid, expired, stale, or already consumed"} + response.FinishedAt = time.Now().UTC() + writeOperationResponse(w, response) + return + } + response = s.registry.ExecuteApproved(r.Context(), envelope.Plan, executor) + s.logOperation(r, response, envelope.Plan.Actor.ID, executor.ID) + writeOperationResponse(w, response) +} + +func rejectedApprovedResponse(plan security.OperationPlan) protocol.OperationResponse { + now := time.Now().UTC() + requestID := plan.RequestID + if requestID == "" { + requestID = newID("request") + } + return protocol.OperationResponse{RequestID: requestID, Action: plan.Action, Status: "rejected", Risk: protocol.Risk(plan.Risk), StartedAt: now, AuditID: newID("audit")} +} + +func (s *Server) logOperation(r *http.Request, response protocol.OperationResponse, planActor, executor string) { s.logger.InfoContext(r.Context(), "broker operation", "audit_id", response.AuditID, "request_id", response.RequestID, - "actor", request.Actor, + "plan_actor", planActor, + "executor", executor, "action", response.Action, "risk", response.Risk, "status", response.Status, "duration_ms", response.FinishedAt.Sub(response.StartedAt).Milliseconds(), ) - status := http.StatusOK - switch response.Status { - case "rejected": - status = http.StatusBadRequest - case "failed": - status = http.StatusInternalServerError - } - writeJSON(w, status, response) } func (s *Server) decodeRequest(w http.ResponseWriter, r *http.Request) (protocol.OperationRequest, bool) { + var request protocol.OperationRequest + if !s.decodeJSON(w, r, &request) { + return protocol.OperationRequest{}, false + } + return request, true +} + +func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { r.Body = http.MaxBytesReader(w, r.Body, s.bodyLimit) decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() - var request protocol.OperationRequest - if err := decoder.Decode(&request); err != nil { - writeProblem(w, http.StatusBadRequest, "invalid_json", err.Error()) - return protocol.OperationRequest{}, false + if err := decoder.Decode(target); err != nil { + writeProblem(w, http.StatusBadRequest, "invalid_json", "valid JSON request required") + return false } if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { writeProblem(w, http.StatusBadRequest, "invalid_json", "request body must contain exactly one JSON object") - return protocol.OperationRequest{}, false + return false } - return request, true + return true } func (s *Server) requireBrokerAuth(next http.Handler) http.Handler { @@ -116,6 +206,17 @@ func (s *Server) requireBrokerAuth(next http.Handler) http.Handler { }) } +func writeOperationResponse(w http.ResponseWriter, response protocol.OperationResponse) { + status := http.StatusOK + switch response.Status { + case "rejected": + status = http.StatusBadRequest + case "failed": + status = http.StatusInternalServerError + } + writeJSON(w, status, response) +} + func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") @@ -125,8 +226,5 @@ func writeJSON(w http.ResponseWriter, status int, payload any) { } func writeProblem(w http.ResponseWriter, status int, code, message string) { - writeJSON(w, status, map[string]any{ - "error": protocol.Problem{Code: code, Message: message}, - "time": time.Now().UTC(), - }) + writeJSON(w, status, map[string]any{"error": protocol.Problem{Code: code, Message: message}, "time": time.Now().UTC()}) } diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index d0590d6..ad4408b 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -2,7 +2,7 @@ package buildinfo // These values may be replaced with -ldflags during release builds. var ( - Version = "0.2.0-alpha.2" + Version = "0.3.0-alpha.1" Commit = "unknown" BuildTime = "unknown" ) diff --git a/internal/config/config.go b/internal/config/config.go index e7c3dcf..5dde1b8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,12 +12,14 @@ import ( ) const ( - defaultControlListen = "127.0.0.1:17440" - defaultBrokerSocket = "/run/quantum-control/qcored.sock" - defaultTokenFile = "/etc/quantum-control/broker.token" - defaultAuditPath = "/var/lib/quantum-control/audit/audit.jsonl" - defaultGrantPath = "/var/lib/quantum-control/security/grants.json" - defaultBodyLimit = int64(1 << 20) + defaultControlListen = "127.0.0.1:17440" + defaultBrokerSocket = "/run/quantum-control/qcored.sock" + defaultTokenFile = "/etc/quantum-control/broker.token" + defaultAuditPath = "/var/lib/quantum-control/audit/audit.jsonl" + defaultBrokerGrantPath = "/var/lib/quantum-control-broker/grants.json" + defaultBodyLimit = int64(1 << 20) + defaultTransactionTimeout = 30 * time.Second + defaultServicePoll = 250 * time.Millisecond ) // Control configures the unprivileged web/API process. @@ -26,9 +28,7 @@ type Control struct { APIToken string ActorFile string AuditPath string - GrantPath string PlanTTL time.Duration - GrantTTL time.Duration BrokerSocket string BrokerToken string RequestBodyLimit int64 @@ -39,11 +39,17 @@ type Control struct { // Broker configures qcored, the privileged typed-operation broker. type Broker struct { - SocketPath string - BrokerToken string - RequestBodyLimit int64 - HeaderTimeout time.Duration - IdleTimeout time.Duration + SocketPath string + BrokerToken string + ActorFile string + GrantPath string + GrantTTL time.Duration + ServicePolicyFile string + TransactionTimeout time.Duration + ServicePollInterval time.Duration + RequestBodyLimit int64 + HeaderTimeout time.Duration + IdleTimeout time.Duration } func LoadControl() (Control, error) { @@ -71,19 +77,13 @@ func LoadControl() (Control, error) { if err != nil { return Control{}, err } - grantTTL, err := envDuration("QUANTUM_CONTROL_GRANT_TTL", 2*time.Minute) - if err != nil { - return Control{}, err - } cfg := Control{ Listen: envOr("QUANTUM_CONTROL_LISTEN", defaultControlListen), APIToken: strings.TrimSpace(os.Getenv("QUANTUM_CONTROL_API_TOKEN")), ActorFile: strings.TrimSpace(os.Getenv("QUANTUM_CONTROL_ACTOR_FILE")), AuditPath: envOr("QUANTUM_CONTROL_AUDIT_PATH", defaultAuditPath), - GrantPath: envOr("QUANTUM_CONTROL_GRANT_PATH", defaultGrantPath), PlanTTL: planTTL, - GrantTTL: grantTTL, BrokerSocket: envOr("QUANTUM_CONTROL_BROKER_SOCKET", defaultBrokerSocket), BrokerToken: brokerToken, RequestBodyLimit: bodyLimit, @@ -114,13 +114,31 @@ func LoadBroker() (Broker, error) { if err != nil { return Broker{}, err } + grantTTL, err := envDuration("QUANTUM_CONTROL_GRANT_TTL", 2*time.Minute) + if err != nil { + return Broker{}, err + } + transactionTimeout, err := envDuration("QUANTUM_CONTROL_TRANSACTION_TIMEOUT", defaultTransactionTimeout) + if err != nil { + return Broker{}, err + } + pollInterval, err := envDuration("QUANTUM_CONTROL_SERVICE_POLL_INTERVAL", defaultServicePoll) + if err != nil { + return Broker{}, err + } cfg := Broker{ - SocketPath: envOr("QUANTUM_CONTROL_BROKER_SOCKET", defaultBrokerSocket), - BrokerToken: brokerToken, - RequestBodyLimit: bodyLimit, - HeaderTimeout: headerTimeout, - IdleTimeout: idleTimeout, + SocketPath: envOr("QUANTUM_CONTROL_BROKER_SOCKET", defaultBrokerSocket), + BrokerToken: brokerToken, + ActorFile: strings.TrimSpace(os.Getenv("QUANTUM_CONTROL_ACTOR_FILE")), + GrantPath: envOr("QUANTUM_CONTROL_GRANT_PATH", defaultBrokerGrantPath), + GrantTTL: grantTTL, + ServicePolicyFile: strings.TrimSpace(os.Getenv("QUANTUM_CONTROL_SERVICE_POLICY_FILE")), + TransactionTimeout: transactionTimeout, + ServicePollInterval: pollInterval, + RequestBodyLimit: bodyLimit, + HeaderTimeout: headerTimeout, + IdleTimeout: idleTimeout, } if err := cfg.Validate(); err != nil { return Broker{}, err @@ -147,15 +165,9 @@ func (c Control) Validate() error { if !filepath.IsAbs(c.AuditPath) { return errors.New("QUANTUM_CONTROL_AUDIT_PATH must be absolute") } - if !filepath.IsAbs(c.GrantPath) { - return errors.New("QUANTUM_CONTROL_GRANT_PATH must be absolute") - } if c.PlanTTL <= 0 || c.PlanTTL > 15*time.Minute { return errors.New("QUANTUM_CONTROL_PLAN_TTL must be greater than zero and at most 15 minutes") } - if c.GrantTTL <= 0 || c.GrantTTL > 15*time.Minute { - return errors.New("QUANTUM_CONTROL_GRANT_TTL must be greater than zero and at most 15 minutes") - } if c.RequestBodyLimit < 1024 { return errors.New("QUANTUM_CONTROL_REQUEST_BODY_LIMIT must be at least 1024 bytes") } @@ -175,6 +187,24 @@ func (c Broker) Validate() error { if len(c.BrokerToken) < 32 { return errors.New("broker token must contain at least 32 characters") } + if c.ActorFile != "" && !filepath.IsAbs(c.ActorFile) { + return errors.New("QUANTUM_CONTROL_ACTOR_FILE must be absolute when configured for qcored") + } + if !filepath.IsAbs(c.GrantPath) { + return errors.New("QUANTUM_CONTROL_GRANT_PATH must be absolute") + } + if c.GrantTTL <= 0 || c.GrantTTL > 15*time.Minute { + return errors.New("QUANTUM_CONTROL_GRANT_TTL must be greater than zero and at most 15 minutes") + } + if c.ServicePolicyFile != "" && !filepath.IsAbs(c.ServicePolicyFile) { + return errors.New("QUANTUM_CONTROL_SERVICE_POLICY_FILE must be absolute when configured") + } + if c.TransactionTimeout < time.Second || c.TransactionTimeout > 2*time.Minute { + return errors.New("QUANTUM_CONTROL_TRANSACTION_TIMEOUT must be between 1 second and 2 minutes") + } + if c.ServicePollInterval < 10*time.Millisecond || c.ServicePollInterval > 5*time.Second { + return errors.New("QUANTUM_CONTROL_SERVICE_POLL_INTERVAL must be between 10 milliseconds and 5 seconds") + } if c.RequestBodyLimit < 1024 { return errors.New("broker request body limit must be at least 1024 bytes") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f9ee024..792427e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -44,38 +44,61 @@ func TestControlRejectsShortAPIToken(t *testing.T) { } } -func TestControlRejectsRelativeSecurityStatePaths(t *testing.T) { +func TestControlRejectsRelativeAuditPath(t *testing.T) { cfg := validControl() cfg.AuditPath = "audit.jsonl" if err := cfg.Validate(); err == nil { t.Fatal("Validate() accepted relative audit path") } - cfg = validControl() - cfg.GrantPath = "grants.json" - if err := cfg.Validate(); err == nil { - t.Fatal("Validate() accepted relative grant path") - } } -func TestControlRejectsOverlongSecurityTTL(t *testing.T) { +func TestControlRejectsOverlongPlanTTL(t *testing.T) { cfg := validControl() cfg.PlanTTL = 16 * time.Minute if err := cfg.Validate(); err == nil { t.Fatal("Validate() accepted overlong plan TTL") } - cfg = validControl() - cfg.GrantTTL = 16 * time.Minute - if err := cfg.Validate(); err == nil { - t.Fatal("Validate() accepted overlong grant TTL") - } } -func TestBrokerRequiresAbsoluteSocket(t *testing.T) { +func TestBrokerRequiresAbsoluteSecurityAndPolicyPaths(t *testing.T) { cfg := validBroker() cfg.SocketPath = "qcored.sock" if err := cfg.Validate(); err == nil { t.Fatal("Validate() accepted relative socket path") } + cfg = validBroker() + cfg.GrantPath = "grants.json" + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() accepted relative grant path") + } + cfg = validBroker() + cfg.ActorFile = "actors.json" + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() accepted relative actor registry path") + } + cfg = validBroker() + cfg.ServicePolicyFile = "policy.json" + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() accepted relative service policy path") + } +} + +func TestBrokerRejectsUnsafeMutationTiming(t *testing.T) { + cfg := validBroker() + cfg.GrantTTL = 16 * time.Minute + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() accepted overlong grant TTL") + } + cfg = validBroker() + cfg.TransactionTimeout = 500 * time.Millisecond + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() accepted sub-second transaction timeout") + } + cfg = validBroker() + cfg.ServicePollInterval = 6 * time.Second + if err := cfg.Validate(); err == nil { + t.Fatal("Validate() accepted overlong service poll interval") + } } func TestLoadControlRejectsInvalidNumericConfiguration(t *testing.T) { @@ -108,9 +131,7 @@ func validControl() Control { return Control{ Listen: "127.0.0.1:17440", AuditPath: "/var/lib/quantum-control/audit/audit.jsonl", - GrantPath: "/var/lib/quantum-control/security/grants.json", PlanTTL: 5 * time.Minute, - GrantTTL: 2 * time.Minute, BrokerSocket: "/run/quantum-control/qcored.sock", BrokerToken: testToken, RequestBodyLimit: defaultBodyLimit, @@ -122,10 +143,14 @@ func validControl() Control { func validBroker() Broker { return Broker{ - SocketPath: "/run/quantum-control/qcored.sock", - BrokerToken: testToken, - RequestBodyLimit: defaultBodyLimit, - HeaderTimeout: 10 * time.Second, - IdleTimeout: 30 * time.Second, + SocketPath: "/run/quantum-control/qcored.sock", + BrokerToken: testToken, + GrantPath: "/var/lib/quantum-control-broker/grants.json", + GrantTTL: 2 * time.Minute, + TransactionTimeout: 30 * time.Second, + ServicePollInterval: 250 * time.Millisecond, + RequestBodyLimit: defaultBodyLimit, + HeaderTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, } } diff --git a/internal/control/mutation_routes.go b/internal/control/mutation_routes.go new file mode 100644 index 0000000..9aa118a --- /dev/null +++ b/internal/control/mutation_routes.go @@ -0,0 +1,104 @@ +package control + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/broker" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/security" +) + +type approvedExecutionRequest struct { + PlanID string `json:"plan_id"` + ConfirmationToken string `json:"confirmation_token"` +} + +func (s *Server) handleApprovedExecute(w http.ResponseWriter, r *http.Request) { + if s.security.Plans == nil { + writeProblem(w, r, http.StatusServiceUnavailable, "mutation_unavailable", "Operation plan storage is unavailable.") + return + } + approvalBroker, ok := s.broker.(broker.ApprovalAPI) + if !ok { + writeProblem(w, r, http.StatusServiceUnavailable, "mutation_unavailable", "The privileged broker does not support approved mutations.") + return + } + r.Body = http.MaxBytesReader(w, r.Body, s.cfg.RequestBodyLimit) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + var request approvedExecutionRequest + if err := decoder.Decode(&request); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeProblem(w, r, http.StatusRequestEntityTooLarge, "request_too_large", "The request body exceeds the configured limit.") + return + } + writeProblem(w, r, http.StatusBadRequest, "invalid_json", "A valid approved execution request is required.") + return + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeProblem(w, r, http.StatusBadRequest, "invalid_json", "The request body must contain exactly one JSON object.") + return + } + request.PlanID = strings.TrimSpace(request.PlanID) + request.ConfirmationToken = strings.TrimSpace(request.ConfirmationToken) + if request.PlanID == "" || len(request.PlanID) > 160 { + writeProblem(w, r, http.StatusBadRequest, "invalid_plan_id", "plan_id is required and must match a cached plan.") + return + } + if request.ConfirmationToken == "" || len(request.ConfirmationToken) > 512 { + writeProblem(w, r, http.StatusBadRequest, "invalid_confirmation_token", "A bounded confirmation token is required.") + return + } + plan, found := s.security.Plans.Get(request.PlanID) + if !found { + writeProblem(w, r, http.StatusNotFound, "plan_not_found", "The operation plan does not exist or has expired.") + return + } + executor := actorFromContext(r.Context()) + parameters := security.PlanParametersMap(plan) + s.appendAudit(security.AuditEvent{ + Event: "operation.attempt", Actor: executor, + RequestID: requestIDFromContext(r.Context()), SessionID: sessionIDFromContext(r.Context()), + PlanID: plan.ID, PlanDigest: plan.Digest, Action: plan.Action, Risk: plan.Risk, + Status: "attempted", RollbackStatus: "not_started", Parameters: parameters, + }) + response, err := approvalBroker.ExecuteApproved(r.Context(), plan, request.ConfirmationToken, actorCredentialFromContext(r.Context())) + if err != nil { + s.logger.ErrorContext(r.Context(), "approved broker execution transport failed", "request_id", requestIDFromContext(r.Context()), "plan_id", plan.ID, "executor", executor.ID, "action", plan.Action, "error", err) + s.appendAudit(security.AuditEvent{ + Event: "operation.unknown", Actor: executor, + RequestID: requestIDFromContext(r.Context()), SessionID: sessionIDFromContext(r.Context()), + PlanID: plan.ID, PlanDigest: plan.Digest, Action: plan.Action, Risk: plan.Risk, + Status: "unknown", RollbackStatus: "unknown", Parameters: parameters, ErrorCode: "broker_transport_failed", + }) + writeProblem(w, r, http.StatusBadGateway, "broker_error", "The privileged broker request failed after the execution attempt was submitted.") + return + } + errorCode := "" + if response.Error != nil { + errorCode = response.Error.Code + } + rollbackStatus := recoveryStatus(response.Result) + if rollbackStatus == "" { + rollbackStatus = "not_required" + } + s.appendAudit(security.AuditEvent{ + Event: "operation." + response.Status, Actor: executor, + RequestID: response.RequestID, SessionID: sessionIDFromContext(r.Context()), + PlanID: plan.ID, PlanDigest: plan.Digest, Action: plan.Action, Risk: plan.Risk, + Status: response.Status, RollbackStatus: rollbackStatus, Parameters: parameters, ErrorCode: errorCode, + }) + writeJSON(w, operationHTTPStatus(response.Status), response) +} + +func recoveryStatus(result map[string]any) string { + if result == nil { + return "" + } + value, _ := result["recovery_status"].(string) + return strings.TrimSpace(value) +} diff --git a/internal/control/security.go b/internal/control/security.go index 0e89402..c454b67 100644 --- a/internal/control/security.go +++ b/internal/control/security.go @@ -15,7 +15,6 @@ import ( type SecurityDependencies struct { Authenticator security.Authenticator Audit *security.AuditStore - Grants *security.GrantStore Plans *security.PlanCache PlanBuilder security.PlanBuilder } @@ -41,10 +40,6 @@ func LoadSecurityDependencies(cfg config.Control) (SecurityDependencies, error) if err != nil { return SecurityDependencies{}, fmt.Errorf("open audit store: %w", err) } - grants, err := security.OpenGrantStore(cfg.GrantPath, cfg.GrantTTL) - if err != nil { - return SecurityDependencies{}, fmt.Errorf("open confirmation store: %w", err) - } var authenticator security.Authenticator if len(authenticators) > 0 { authenticator = authenticators @@ -52,7 +47,6 @@ func LoadSecurityDependencies(cfg config.Control) (SecurityDependencies, error) return SecurityDependencies{ Authenticator: authenticator, Audit: audit, - Grants: grants, Plans: security.NewPlanCache(), PlanBuilder: security.PlanBuilder{TTL: cfg.PlanTTL}, }, nil @@ -74,6 +68,7 @@ func defaultSecurityDependencies(cfg config.Control) SecurityDependencies { type actorContextKey struct{} type sessionContextKey struct{} +type actorCredentialContextKey struct{} func actorFromContext(ctx context.Context) security.Actor { actor, _ := ctx.Value(actorContextKey{}).(security.Actor) @@ -85,9 +80,14 @@ func sessionIDFromContext(ctx context.Context) string { return value } +func actorCredentialFromContext(ctx context.Context) string { + value, _ := ctx.Value(actorCredentialContextKey{}).(string) + return value +} + func (s *Server) requirePermission(permission security.Permission, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - actor, ok := s.authenticateRequest(r) + actor, credential, ok := s.authenticateRequest(r) if !ok { w.Header().Set("WWW-Authenticate", `Bearer realm="Quantum Control"`) writeProblem(w, r, http.StatusUnauthorized, "unauthorized", "A valid actor credential is required.") @@ -106,24 +106,29 @@ func (s *Server) requirePermission(permission security.Permission, next http.Han } ctx := context.WithValue(r.Context(), actorContextKey{}, actor) ctx = context.WithValue(ctx, sessionContextKey{}, sessionID) + ctx = context.WithValue(ctx, actorCredentialContextKey{}, credential) next.ServeHTTP(w, r.WithContext(ctx)) }) } -func (s *Server) authenticateRequest(r *http.Request) (security.Actor, bool) { +func (s *Server) authenticateRequest(r *http.Request) (security.Actor, string, bool) { const prefix = "Bearer " header := r.Header.Get("Authorization") if s.security.Authenticator == nil { if strings.TrimSpace(header) != "" { - return security.Actor{}, false + return security.Actor{}, "", false } - return security.LocalReadOnlyActor(), true + return security.LocalReadOnlyActor(), "", true } if !strings.HasPrefix(header, prefix) { - return security.Actor{}, false + return security.Actor{}, "", false } provided := strings.TrimSpace(strings.TrimPrefix(header, prefix)) - return s.security.Authenticator.AuthenticateBearer(provided) + actor, ok := s.security.Authenticator.AuthenticateBearer(provided) + if !ok { + return security.Actor{}, "", false + } + return actor, provided, true } func validCorrelationID(value string) bool { diff --git a/internal/control/security_routes.go b/internal/control/security_routes.go index 9b37ff6..1c8dc7d 100644 --- a/internal/control/security_routes.go +++ b/internal/control/security_routes.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/Starlight-Unit-Studio/Quantum-Control/internal/broker" "github.com/Starlight-Unit-Studio/Quantum-Control/internal/security" ) @@ -50,8 +51,13 @@ func (s *Server) handleAuditIntegrity(w http.ResponseWriter, _ *http.Request) { } func (s *Server) handleConfirmation(w http.ResponseWriter, r *http.Request) { - if s.security.Grants == nil || s.security.Plans == nil { - writeProblem(w, r, http.StatusServiceUnavailable, "confirmation_unavailable", "Confirmation storage is unavailable.") + if s.security.Plans == nil { + writeProblem(w, r, http.StatusServiceUnavailable, "confirmation_unavailable", "Operation plan storage is unavailable.") + return + } + approvalBroker, ok := s.broker.(broker.ApprovalAPI) + if !ok { + writeProblem(w, r, http.StatusServiceUnavailable, "confirmation_unavailable", "The privileged broker does not support structured confirmations.") return } r.Body = http.MaxBytesReader(w, r.Body, s.cfg.RequestBodyLimit) @@ -82,8 +88,14 @@ func (s *Server) handleConfirmation(w http.ResponseWriter, r *http.Request) { return } approver := actorFromContext(r.Context()) - grant, err := s.security.Grants.Issue(plan, approver) + grant, err := approvalBroker.Confirm(r.Context(), plan, actorCredentialFromContext(r.Context())) if err != nil { + s.logger.WarnContext(r.Context(), "broker confirmation rejected", + "request_id", requestIDFromContext(r.Context()), + "plan_id", plan.ID, + "approver", approver.ID, + "action", plan.Action, + ) writeProblem(w, r, http.StatusBadRequest, "confirmation_rejected", "The operation plan cannot be confirmed under the current policy.") return } diff --git a/internal/control/security_routes_test.go b/internal/control/security_routes_test.go index 54f63c9..0a80ac8 100644 --- a/internal/control/security_routes_test.go +++ b/internal/control/security_routes_test.go @@ -94,6 +94,17 @@ func TestTCICanPlanButCannotExecuteOrConfirm(t *testing.T) { if response.StatusCode != http.StatusForbidden { t.Fatalf("TCI reached confirmation minting route: %d", response.StatusCode) } + + request, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/operations/execute-approved", bytes.NewBufferString(`{"plan_id":"anything","confirmation_token":"forged"}`)) + request.Header.Set("Authorization", "Bearer "+testTCIToken) + response, err = http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("TCI reached approved mutation route: %d", response.StatusCode) + } } func TestAuthenticatedActorOverridesCallerActorField(t *testing.T) { @@ -121,10 +132,6 @@ func testSecurityDependencies(t *testing.T) SecurityDependencies { if err != nil { t.Fatal(err) } - grants, err := security.OpenGrantStore(filepath.Join(dir, "grants.json"), 2*time.Minute) - if err != nil { - t.Fatal(err) - } auditor, err := security.NewStaticAuthenticator( security.Actor{ID: "human:auditor", Kind: security.ActorHuman, DisplayName: "Auditor", Roles: []string{"auditor"}}, testAuditorToken, @@ -142,7 +149,6 @@ func testSecurityDependencies(t *testing.T) SecurityDependencies { return SecurityDependencies{ Authenticator: security.MultiAuthenticator{auditor, tci}, Audit: audit, - Grants: grants, Plans: security.NewPlanCache(), PlanBuilder: security.PlanBuilder{TTL: 5 * time.Minute}, } diff --git a/internal/control/server.go b/internal/control/server.go index fe6d88f..93ecd76 100644 --- a/internal/control/server.go +++ b/internal/control/server.go @@ -55,6 +55,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /v1/operations", s.requirePermission(security.PermissionOperationCatalog, http.HandlerFunc(s.handleOperations))) mux.Handle("POST /v1/operations/plan", s.requirePermission(security.PermissionOperationPlan, http.HandlerFunc(s.handlePlan))) mux.Handle("POST /v1/operations/execute", s.requirePermission(security.PermissionOperationExecute, http.HandlerFunc(s.handleExecute))) + mux.Handle("POST /v1/operations/execute-approved", s.requirePermission(security.PermissionOperationMutate, http.HandlerFunc(s.handleApprovedExecute))) mux.Handle("GET /v1/system/status", s.requirePermission(security.PermissionControlRead, http.HandlerFunc(s.handleSystemStatus))) mux.Handle("GET /v1/services/{unit}", s.requirePermission(security.PermissionControlRead, http.HandlerFunc(s.handleServiceStatus))) mux.Handle("GET /v1/audit", s.requirePermission(security.PermissionAuditRead, http.HandlerFunc(s.handleAuditQuery))) @@ -94,6 +95,7 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleInfo(w http.ResponseWriter, _ *http.Request) { + _, mutationCapable := s.broker.(broker.ApprovalAPI) writeJSON(w, http.StatusOK, map[string]any{ "service": "quantum-control", "version": buildinfo.Version, @@ -108,7 +110,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, _ *http.Request) { "durable_audit": s.security.Audit != nil, "system_snapshot": true, "service_status": true, - "mutations": false, + "service_mutations": mutationCapable, "web_ui": false, }, }) diff --git a/internal/control/server_test.go b/internal/control/server_test.go index 8a61d20..a477bf8 100644 --- a/internal/control/server_test.go +++ b/internal/control/server_test.go @@ -46,11 +46,7 @@ func (f *fakeBroker) Plan(_ context.Context, request protocol.OperationRequest) if f.planValid != nil { valid = *f.planValid } - plan := protocol.OperationPlan{ - Request: request, - Definition: protocol.OperationDefinition{Action: request.Action, Risk: protocol.RiskReadOnly, Implemented: true}, - Valid: valid, - } + plan := protocol.OperationPlan{Request: request, Definition: protocol.OperationDefinition{Action: request.Action, Risk: protocol.RiskReadOnly, Implemented: true}, Valid: valid} if !valid { plan.Error = &protocol.Problem{Code: "unknown_action", Message: "action is not allowlisted"} } @@ -65,24 +61,13 @@ func (f *fakeBroker) Execute(_ context.Context, request protocol.OperationReques if status == "" { status = "completed" } - return protocol.OperationResponse{ - RequestID: request.RequestID, - Action: request.Action, - Status: status, - Risk: protocol.RiskReadOnly, - StartedAt: time.Now().UTC(), - FinishedAt: time.Now().UTC(), - AuditID: "audit-1", - Result: map[string]any{"hostname": "node-1", "unit": request.Parameters["unit"]}, - Error: f.executeProblem, - }, nil + return protocol.OperationResponse{RequestID: request.RequestID, Action: request.Action, Status: status, Risk: protocol.RiskReadOnly, StartedAt: time.Now().UTC(), FinishedAt: time.Now().UTC(), AuditID: "audit-1", Result: map[string]any{"hostname": "node-1", "unit": request.Parameters["unit"]}, Error: f.executeProblem}, nil } func TestReadyReportsBrokerFailureWithoutLeakingInternalError(t *testing.T) { broker := &fakeBroker{healthErr: errors.New("secret socket path /run/private.sock")} server := httptest.NewServer(newTestServer(broker, "")) defer server.Close() - response, err := http.Get(server.URL + "/readyz") if err != nil { t.Fatal(err) @@ -99,12 +84,10 @@ func TestReadyReportsBrokerFailureWithoutLeakingInternalError(t *testing.T) { t.Fatal("missing request ID header") } } - func TestSystemStatusUsesTypedBrokerAction(t *testing.T) { broker := &fakeBroker{} server := httptest.NewServer(newTestServer(broker, "")) defer server.Close() - response, err := http.Get(server.URL + "/v1/system/status") if err != nil { t.Fatal(err) @@ -124,12 +107,10 @@ func TestSystemStatusUsesTypedBrokerAction(t *testing.T) { t.Fatal("system status did not propagate the HTTP request ID") } } - func TestServiceStatusUsesPathAsTypedParameter(t *testing.T) { broker := &fakeBroker{} server := httptest.NewServer(newTestServer(broker, "")) defer server.Close() - response, err := http.Get(server.URL + "/v1/services/quantum-runtime.service") if err != nil { t.Fatal(err) @@ -139,11 +120,9 @@ func TestServiceStatusUsesPathAsTypedParameter(t *testing.T) { t.Fatalf("unexpected status/request: %d %#v", response.StatusCode, broker.last) } } - func TestControlAuthenticationRequiresBearerScheme(t *testing.T) { server := httptest.NewServer(newTestServer(&fakeBroker{}, testAPIToken)) defer server.Close() - request, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/control/info", nil) request.Header.Set("Authorization", testAPIToken) response, err := http.DefaultClient.Do(request) @@ -155,11 +134,9 @@ func TestControlAuthenticationRequiresBearerScheme(t *testing.T) { t.Fatalf("raw token without Bearer scheme was accepted: %d", response.StatusCode) } } - func TestControlAuthenticationAcceptsValidBearerToken(t *testing.T) { server := httptest.NewServer(newTestServer(&fakeBroker{}, testAPIToken)) defer server.Close() - request, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/control/info", nil) request.Header.Set("Authorization", "Bearer "+testAPIToken) response, err := http.DefaultClient.Do(request) @@ -171,18 +148,12 @@ func TestControlAuthenticationAcceptsValidBearerToken(t *testing.T) { t.Fatalf("valid bearer token was rejected: %d", response.StatusCode) } } - func TestInvalidPlanReturnsBadRequest(t *testing.T) { valid := false broker := &fakeBroker{planValid: &valid} server := httptest.NewServer(newTestServer(broker, "")) defer server.Close() - - response, err := http.Post( - server.URL+"/v1/operations/plan", - "application/json", - bytes.NewBufferString(`{"action":"shell.exec"}`), - ) + response, err := http.Post(server.URL+"/v1/operations/plan", "application/json", bytes.NewBufferString(`{"action":"shell.exec"}`)) if err != nil { t.Fatal(err) } @@ -191,20 +162,11 @@ func TestInvalidPlanReturnsBadRequest(t *testing.T) { t.Fatalf("invalid plan returned %d", response.StatusCode) } } - func TestRejectedExecutionReturnsBadRequest(t *testing.T) { - broker := &fakeBroker{ - executeStatus: "rejected", - executeProblem: &protocol.Problem{Code: "unknown_action", Message: "action is not allowlisted"}, - } + broker := &fakeBroker{executeStatus: "rejected", executeProblem: &protocol.Problem{Code: "unknown_action", Message: "action is not allowlisted"}} server := httptest.NewServer(newTestServer(broker, "")) defer server.Close() - - response, err := http.Post( - server.URL+"/v1/operations/execute", - "application/json", - bytes.NewBufferString(`{"action":"shell.exec"}`), - ) + response, err := http.Post(server.URL+"/v1/operations/execute", "application/json", bytes.NewBufferString(`{"action":"shell.exec"}`)) if err != nil { t.Fatal(err) } @@ -213,14 +175,12 @@ func TestRejectedExecutionReturnsBadRequest(t *testing.T) { t.Fatalf("rejected execution returned %d", response.StatusCode) } } - func TestOversizedOperationBodyReturnsRequestTooLarge(t *testing.T) { cfg := testConfig("") cfg.RequestBodyLimit = 1024 logger := slog.New(slog.NewTextHandler(io.Discard, nil)) server := httptest.NewServer(NewServer(&fakeBroker{}, cfg, logger).Handler()) defer server.Close() - body := `{"action":"system.snapshot","confirmation":"` + strings.Repeat("x", 2048) + `"}` response, err := http.Post(server.URL+"/v1/operations/plan", "application/json", strings.NewReader(body)) if err != nil { @@ -231,25 +191,10 @@ func TestOversizedOperationBodyReturnsRequestTooLarge(t *testing.T) { t.Fatalf("oversized request returned %d", response.StatusCode) } } - func newTestServer(client *fakeBroker, token string) http.Handler { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) return NewServer(client, testConfig(token), logger).Handler() } - func testConfig(token string) config.Control { - return config.Control{ - Listen: "127.0.0.1:17440", - APIToken: token, - AuditPath: "/tmp/quantum-control-audit-test.jsonl", - GrantPath: "/tmp/quantum-control-grants-test.json", - PlanTTL: 5 * time.Minute, - GrantTTL: 2 * time.Minute, - BrokerSocket: "/tmp/qcored.sock", - BrokerToken: testAPIToken, - RequestBodyLimit: 1 << 20, - HeaderTimeout: 10 * time.Second, - IdleTimeout: 90 * time.Second, - BrokerTimeout: 15 * time.Second, - } + return config.Control{Listen: "127.0.0.1:17440", APIToken: token, AuditPath: "/tmp/quantum-control-audit-test.jsonl", PlanTTL: 5 * time.Minute, BrokerSocket: "/tmp/qcored.sock", BrokerToken: testAPIToken, RequestBodyLimit: 1 << 20, HeaderTimeout: 10 * time.Second, IdleTimeout: 90 * time.Second, BrokerTimeout: 15 * time.Second} } diff --git a/internal/security/auth.go b/internal/security/auth.go index 40d1e0f..5828b05 100644 --- a/internal/security/auth.go +++ b/internal/security/auth.go @@ -28,6 +28,14 @@ var rolePermissions = map[string][]Permission{ PermissionOperationPlan, PermissionOperationExecute, }, + "mutator": { + PermissionControlRead, + PermissionInventoryRead, + PermissionOperationCatalog, + PermissionOperationPlan, + PermissionOperationExecute, + PermissionOperationMutate, + }, "auditor": { PermissionControlRead, PermissionAuditRead, @@ -218,6 +226,9 @@ func normalizeActor(actor *Actor) error { if actor.Kind == ActorTCI && HasPermission(Actor{Permissions: permissions}, PermissionConfirm) { return errors.New("TCI actor may not receive confirmation permission") } + if actor.Kind == ActorTCI && HasPermission(Actor{Permissions: permissions}, PermissionOperationMutate) { + return errors.New("TCI actor may not receive mutation permission") + } actor.Roles = roles actor.Permissions = permissions return nil diff --git a/internal/security/grants.go b/internal/security/grants.go index 74d8c53..9c7d2a4 100644 --- a/internal/security/grants.go +++ b/internal/security/grants.go @@ -59,7 +59,7 @@ func (s *GrantStore) Issue(plan OperationPlan, approver Actor) (GrantResponse, e defer s.mu.Unlock() now := s.now().UTC() - if !plan.Valid || !plan.RequiresConfirmation { + if plan.Schema != PlanSchema || !plan.Valid || !plan.RequiresConfirmation { return GrantResponse{}, errors.New("plan does not require confirmation") } if !VerifyPlanDigest(plan) { @@ -75,10 +75,13 @@ func (s *GrantStore) Issue(plan OperationPlan, approver Actor) (GrantResponse, e return GrantResponse{}, errors.New("only an authenticated human approver may issue confirmation grants") } if plan.Actor.ID == approver.ID { - // Self-approval may be permitted by a future policy for low-risk actions, - // but the v1alpha1 contract deliberately requires separation of duties. return GrantResponse{}, errors.New("v1alpha1 requires a distinct human approver") } + for _, existing := range s.data.Grants { + if existing.Grant.PlanID == plan.ID && strings.EqualFold(existing.Grant.PlanDigest, plan.Digest) { + return GrantResponse{}, errors.New("a confirmation grant already exists for this plan") + } + } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { @@ -117,11 +120,17 @@ func (s *GrantStore) Consume(token string, plan OperationPlan, subjectActorID, a if strings.TrimSpace(token) == "" { return ConfirmationGrant{}, errors.New("confirmation token is required") } + if plan.Schema != PlanSchema || !plan.Valid || !plan.RequiresConfirmation { + return ConfirmationGrant{}, errors.New("operation plan is not confirmation-executable") + } if !VerifyPlanDigest(plan) { return ConfirmationGrant{}, errors.New("plan digest verification failed") } - provided := sha256.Sum256([]byte(token)) now := s.now().UTC() + if !plan.ExpiresAt.After(now) { + return ConfirmationGrant{}, errors.New("operation plan has expired") + } + provided := sha256.Sum256([]byte(token)) for index := range s.data.Grants { stored := &s.data.Grants[index] expected, err := decodeSHA256(stored.TokenSHA256) diff --git a/internal/security/types.go b/internal/security/types.go index 8382618..93b218c 100644 --- a/internal/security/types.go +++ b/internal/security/types.go @@ -2,33 +2,28 @@ package security import "time" -const ( - ActorSchema = "quantum.control/actors/v1alpha1" - PlanSchema = "quantum.control/operation-plan/v1alpha1" - GrantSchema = "quantum.control/confirmation-grant/v1alpha1" - AuditSchema = "quantum.control/audit-record/v1alpha1" -) +const ActorSchema = "quantum.control/actors/v1alpha1" +const PlanSchema = "quantum.control/operation-plan/v1alpha1" +const GrantSchema = "quantum.control/confirmation-grant/v1alpha1" +const AuditSchema = "quantum.control/audit-record/v1alpha1" type ActorKind string -const ( - ActorHuman ActorKind = "human" - ActorService ActorKind = "service" - ActorTCI ActorKind = "tci" -) +const ActorHuman ActorKind = "human" +const ActorService ActorKind = "service" +const ActorTCI ActorKind = "tci" type Permission string -const ( - PermissionControlRead Permission = "control.read" - PermissionInventoryRead Permission = "inventory.read" - PermissionOperationCatalog Permission = "operations.catalog.read" - PermissionOperationPlan Permission = "operations.plan" - PermissionOperationExecute Permission = "operations.execute.readonly" - PermissionAuditRead Permission = "audit.read" - PermissionConfirm Permission = "operations.confirm" - PermissionTCIPropose Permission = "operations.propose" -) +const PermissionControlRead Permission = "control.read" +const PermissionInventoryRead Permission = "inventory.read" +const PermissionOperationCatalog Permission = "operations.catalog.read" +const PermissionOperationPlan Permission = "operations.plan" +const PermissionOperationExecute Permission = "operations.execute.readonly" +const PermissionOperationMutate Permission = "operations.execute.mutate" +const PermissionAuditRead Permission = "audit.read" +const PermissionConfirm Permission = "operations.confirm" +const PermissionTCIPropose Permission = "operations.propose" type Actor struct { ID string `json:"id"` diff --git a/internal/servicecontrol/controller.go b/internal/servicecontrol/controller.go new file mode 100644 index 0000000..55ab90a --- /dev/null +++ b/internal/servicecontrol/controller.go @@ -0,0 +1,96 @@ +package servicecontrol + +import ( + "context" + "errors" + "fmt" + "os/exec" + "regexp" + "strings" +) + +var unitPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.@:-]{0,127}$`) + +// Mutator exposes only the three typed service lifecycle actions supported by +// the first Quantum Control mutation milestone. It never accepts command text. +type Mutator interface { + Start(context.Context, string) error + Stop(context.Context, string) error + Restart(context.Context, string) error +} + +// Runner exists so the fixed systemctl argument vectors can be verified in +// tests without invoking the host service manager. +type Runner interface { + Run(context.Context, string, ...string) error +} + +type execRunner struct{} + +func (execRunner) Run(ctx context.Context, binary string, args ...string) error { + command := exec.CommandContext(ctx, binary, args...) + if output, err := command.CombinedOutput(); err != nil { + message := strings.TrimSpace(string(output)) + if len(message) > 256 { + message = message[:256] + } + if message == "" { + return fmt.Errorf("systemctl action failed: %w", err) + } + return fmt.Errorf("systemctl action failed: %s", message) + } + return nil +} + +// Native executes systemctl directly with a fixed argv vector. Binary and +// Runner are injectable only for tests; production uses exec.LookPath and +// exec.CommandContext. +type Native struct { + Binary string + Runner Runner +} + +func (n Native) Start(ctx context.Context, unit string) error { + return n.run(ctx, "start", unit) +} + +func (n Native) Stop(ctx context.Context, unit string) error { + return n.run(ctx, "stop", unit) +} + +func (n Native) Restart(ctx context.Context, unit string) error { + return n.run(ctx, "restart", unit) +} + +func (n Native) run(ctx context.Context, verb, unit string) error { + if err := validateUnit(unit); err != nil { + return err + } + switch verb { + case "start", "stop", "restart": + default: + return errors.New("unsupported service action") + } + binary := strings.TrimSpace(n.Binary) + if binary == "" { + path, err := exec.LookPath("systemctl") + if err != nil { + return errors.New("systemctl is not available") + } + binary = path + } + runner := n.Runner + if runner == nil { + runner = execRunner{} + } + // The separator is fixed and the unit is validated independently. No shell + // parser or request-derived executable/option can enter this vector. + return runner.Run(ctx, binary, verb, "--", unit) +} + +func validateUnit(unit string) error { + if !unitPattern.MatchString(unit) { + return errors.New("systemd unit does not match policy") + } + return nil +} diff --git a/internal/servicecontrol/controller_test.go b/internal/servicecontrol/controller_test.go new file mode 100644 index 0000000..d25e5a5 --- /dev/null +++ b/internal/servicecontrol/controller_test.go @@ -0,0 +1,58 @@ +package servicecontrol + +import ( + "context" + "reflect" + "testing" +) + +type recordingRunner struct { + binary string + args []string +} + +func (r *recordingRunner) Run(_ context.Context, binary string, args ...string) error { + r.binary = binary + r.args = append([]string{}, args...) + return nil +} + +func TestNativeRestartUsesFixedArgumentVector(t *testing.T) { + runner := &recordingRunner{} + mutator := Native{Binary: "/usr/bin/systemctl", Runner: runner} + if err := mutator.Restart(context.Background(), "quantum-runtime.service"); err != nil { + t.Fatal(err) + } + if runner.binary != "/usr/bin/systemctl" { + t.Fatalf("unexpected binary: %q", runner.binary) + } + want := []string{"restart", "--", "quantum-runtime.service"} + if !reflect.DeepEqual(runner.args, want) { + t.Fatalf("unexpected argv: %#v", runner.args) + } +} + +func TestNativeRejectsCommandLikeUnit(t *testing.T) { + for _, unit := range []string{"--now", "quantum-runtime.service --no-block", "", " runtime.service"} { + runner := &recordingRunner{} + if err := (Native{Binary: "/usr/bin/systemctl", Runner: runner}).Restart(context.Background(), unit); err == nil { + t.Fatalf("accepted unsafe unit %q", unit) + } + if len(runner.args) != 0 { + t.Fatalf("runner invoked for unsafe unit %q", unit) + } + } +} + +func TestDefaultPolicyContainsOnlyQuantumRuntime(t *testing.T) { + policy := DefaultPolicy() + units := policy.AllowedUnits() + if !reflect.DeepEqual(units, []string{"quantum-runtime.service"}) { + t.Fatalf("unexpected compiled mutation allowlist: %#v", units) + } + for _, unit := range []string{"quantum-control.service", "ollama.service", "apache2.service"} { + if policy.Allows(unit) { + t.Fatalf("default policy unexpectedly allows %q", unit) + } + } +} diff --git a/internal/servicecontrol/health.go b/internal/servicecontrol/health.go new file mode 100644 index 0000000..61fc110 --- /dev/null +++ b/internal/servicecontrol/health.go @@ -0,0 +1,63 @@ +package servicecontrol + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "time" +) + +// HealthChecker verifies only compile-time service health endpoints supplied by +// Policy. Request data never controls the destination URL. +type HealthChecker interface { + Check(context.Context, string) error +} + +type HTTPHealth struct { + Client *http.Client +} + +func (h HTTPHealth) Check(ctx context.Context, rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme != "http" || parsed.Host == "" { + return errors.New("invalid service health URL") + } + host := parsed.Hostname() + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return errors.New("service health URL must use a loopback address") + } + client := h.Client + if client == nil { + transport := &http.Transport{ + Proxy: nil, + DisableCompression: true, + MaxIdleConns: 2, + IdleConnTimeout: 5 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + } + client = &http.Client{ + Transport: transport, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return errors.New("service health redirects are not allowed") + }, + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return errors.New("create service health request") + } + req.Header.Set("User-Agent", "Quantum-Control/health-probe") + resp, err := client.Do(req) + if err != nil { + return errors.New("service health endpoint is unavailable") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("service health endpoint returned HTTP %d", resp.StatusCode) + } + return nil +} diff --git a/internal/servicecontrol/policy.go b/internal/servicecontrol/policy.go new file mode 100644 index 0000000..a04ef61 --- /dev/null +++ b/internal/servicecontrol/policy.go @@ -0,0 +1,98 @@ +package servicecontrol + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" +) + +const PolicySchema = "quantum.control/service-mutation-policy/v1alpha1" + +type ServiceSpec struct { + Unit string + HealthURL string +} + +var compiledServices = map[string]ServiceSpec{ + "quantum-runtime.service": { + Unit: "quantum-runtime.service", + HealthURL: "http://127.0.0.1:11450/healthz", + }, +} + +type policyFile struct { + Schema string `json:"schema"` + AllowedUnits []string `json:"allowed_units"` +} + +// Policy can only narrow the compiled service set. A deployment file can +// disable services, but it cannot introduce a new privileged target. +type Policy struct { + allowed map[string]ServiceSpec +} + +func DefaultPolicy() Policy { + allowed := make(map[string]ServiceSpec, len(compiledServices)) + for unit, spec := range compiledServices { + allowed[unit] = spec + } + return Policy{allowed: allowed} +} + +func LoadPolicy(path string) (Policy, error) { + path = strings.TrimSpace(path) + if path == "" { + return DefaultPolicy(), nil + } + data, err := os.ReadFile(path) + if err != nil { + return Policy{}, fmt.Errorf("read service mutation policy: %w", err) + } + if len(data) > 1<<20 { + return Policy{}, errors.New("service mutation policy exceeds 1 MiB") + } + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.DisallowUnknownFields() + var document policyFile + if err := decoder.Decode(&document); err != nil { + return Policy{}, fmt.Errorf("decode service mutation policy: %w", err) + } + if document.Schema != PolicySchema { + return Policy{}, fmt.Errorf("unsupported service mutation policy schema %q", document.Schema) + } + allowed := make(map[string]ServiceSpec, len(document.AllowedUnits)) + for _, raw := range document.AllowedUnits { + unit := strings.TrimSpace(raw) + if unit == "" { + return Policy{}, errors.New("service mutation policy contains an empty unit") + } + spec, ok := compiledServices[unit] + if !ok { + return Policy{}, fmt.Errorf("service mutation policy may not broaden compiled allowlist with %q", unit) + } + allowed[unit] = spec + } + return Policy{allowed: allowed}, nil +} + +func (p Policy) Allows(unit string) bool { + _, ok := p.allowed[unit] + return ok +} + +func (p Policy) Spec(unit string) (ServiceSpec, bool) { + spec, ok := p.allowed[unit] + return spec, ok +} + +func (p Policy) AllowedUnits() []string { + units := make([]string, 0, len(p.allowed)) + for unit := range p.allowed { + units = append(units, unit) + } + sort.Strings(units) + return units +} diff --git a/schema/actor-registry-v1alpha1.schema.json b/schema/actor-registry-v1alpha1.schema.json index cc2bbcd..3ddc798 100644 --- a/schema/actor-registry-v1alpha1.schema.json +++ b/schema/actor-registry-v1alpha1.schema.json @@ -34,7 +34,7 @@ "type": "array", "minItems": 1, "uniqueItems": true, - "items": {"enum": ["reader", "operator", "auditor", "approver", "tci-proposer", "service"]} + "items": {"enum": ["reader", "operator", "mutator", "auditor", "approver", "tci-proposer", "service"]} }, "permissions": { "type": "array", @@ -46,6 +46,7 @@ "operations.catalog.read", "operations.plan", "operations.execute.readonly", + "operations.execute.mutate", "audit.read", "operations.confirm", "operations.propose" diff --git a/schema/service-mutation-policy-v1alpha1.schema.json b/schema/service-mutation-policy-v1alpha1.schema.json new file mode 100644 index 0000000..7cf812a --- /dev/null +++ b/schema/service-mutation-policy-v1alpha1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://starlight-unit.de/schemas/quantum-control/service-mutation-policy-v1alpha1.schema.json", + "title": "Quantum Control Service Mutation Policy v1alpha1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "allowed_units"], + "properties": { + "schema": {"const": "quantum.control/service-mutation-policy/v1alpha1"}, + "allowed_units": { + "type": "array", + "uniqueItems": true, + "items": {"enum": ["quantum-runtime.service"]} + } + } +}