From 1c05619899cb50d2c1d2323b8d4373084c81de97 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Mon, 31 Aug 2026 11:33:48 +0300 Subject: [PATCH] feat(authority): add delegated control-plane grants --- adapters/postgres/authority_repository.go | 26 +++ ...-noninteractive-control-plane-authority.md | 62 +++++++ cmd/commit-runtime/control_plane_authority.go | 169 ++++++++++++++++++ .../control_plane_authority_test.go | 91 ++++++++++ cmd/commit-runtime/main.go | 3 + cmd/core-runtime/control_plane_authority.go | 76 ++++++++ .../control_plane_authority_test.go | 25 +++ cmd/core-runtime/main.go | 140 ++++++++++++++- contracts/authority/control_plane.go | 14 ++ contracts/authority/types.go | 67 +++++++ contracts/secrets/types.go | 6 +- .../056_delegated_control_plane_authority.sql | 36 ++++ deploy/docker-compose.candidate-slot.yml | 1 + deploy/docker-compose.prod.yml | 4 + dev/ACTIVE_TASK.json | 2 +- ...oninteractive-control-plane-authority.json | 28 +++ development/GEN2_TASK_INDEX.md | 6 + docs/CREDENTIALS_AND_SECRETS.md | 2 + ...ty-001-secret-capability-gap-2026-08-31.md | 40 +++++ .../domain/delegated_control_plane.go | 34 ++++ .../domain/delegated_control_plane_test.go | 43 +++++ 21 files changed, 865 insertions(+), 10 deletions(-) create mode 100644 architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md create mode 100644 cmd/commit-runtime/control_plane_authority.go create mode 100644 cmd/commit-runtime/control_plane_authority_test.go create mode 100644 cmd/core-runtime/control_plane_authority.go create mode 100644 cmd/core-runtime/control_plane_authority_test.go create mode 100644 contracts/authority/control_plane.go create mode 100644 database/migrations/056_delegated_control_plane_authority.sql create mode 100644 dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json create mode 100644 docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md create mode 100644 modules/authority/domain/delegated_control_plane.go create mode 100644 modules/authority/domain/delegated_control_plane_test.go diff --git a/adapters/postgres/authority_repository.go b/adapters/postgres/authority_repository.go index 08dbc69..dda693c 100644 --- a/adapters/postgres/authority_repository.go +++ b/adapters/postgres/authority_repository.go @@ -3,6 +3,7 @@ package postgres import ( "context" "fmt" + "time" "git.cin.su/m/Assistant/contracts/authority" "github.com/jackc/pgx/v5/pgxpool" @@ -27,3 +28,28 @@ func (r *AuthorityRepository) LoadGrant(ctx context.Context, id authority.GrantI } return grant, scope, nil } + +func (r *AuthorityRepository) LoadDelegatedControlPlaneGrant(ctx context.Context, id authority.GrantID) (authority.DelegatedControlPlaneGrant, error) { + var grant authority.DelegatedControlPlaneGrant + err := r.pool.QueryRow(ctx, `SELECT grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,binding_id,provider_instance_id,model_descriptor_id,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status FROM authority.delegated_control_plane_grants WHERE grant_id=$1`, id).Scan(&grant.ID, &grant.ServiceActorID, &grant.ServicePrincipalID, &grant.IssuerPrincipalID, &grant.TaskID, &grant.TaskRevision, &grant.Operations, &grant.Resource.BindingID, &grant.Resource.ProviderInstanceID, &grant.Resource.ModelDescriptorID, &grant.IssuedAtUnixMillis, &grant.ExpiresAtUnixMillis, &grant.ArchitectureRevision, &grant.Nonce, &grant.Revision, &grant.Status) + if err != nil { + return authority.DelegatedControlPlaneGrant{}, fmt.Errorf("load delegated control-plane grant: %w", err) + } + return grant, nil +} + +func (r *AuthorityRepository) RecordDelegatedControlPlaneAudit(ctx context.Context, audit authority.DelegatedControlPlaneAudit) error { + _, err := r.pool.Exec(ctx, `INSERT INTO authority.delegated_control_plane_audit (audit_id,grant_id,service_actor_id,service_principal_id,task_id,task_revision,operation,binding_id,provider_instance_id,model_descriptor_id,decision,outcome,recorded_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, audit.AuditID, audit.GrantID, audit.ServiceActorID, audit.ServicePrincipalID, audit.TaskID, audit.TaskRevision, audit.Operation, audit.Resource.BindingID, audit.Resource.ProviderInstanceID, audit.Resource.ModelDescriptorID, audit.Decision, audit.Outcome, audit.RecordedAtUnixMillis) + if err != nil { + return fmt.Errorf("record delegated control-plane audit: %w", err) + } + return nil +} + +func (r *AuthorityRepository) RevokeDelegatedControlPlaneGrant(ctx context.Context, id authority.GrantID, issuer string) (bool, error) { + result, err := r.pool.Exec(ctx, `UPDATE authority.delegated_control_plane_grants SET status='REVOKED',revision=revision+1,revoked_at_unix_millis=$3 WHERE grant_id=$1 AND issuer_principal_id=$2 AND status='ACTIVE'`, id, issuer, time.Now().UnixMilli()) + if err != nil { + return false, fmt.Errorf("revoke delegated control-plane grant: %w", err) + } + return result.RowsAffected() == 1, nil +} diff --git a/architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md b/architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md new file mode 100644 index 0000000..4f99e8c --- /dev/null +++ b/architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md @@ -0,0 +1,62 @@ +# ADR-0021 — Delegated Non-Interactive Control-Plane Authority + +Status: Accepted +Date: 2026-08-31 +Accepted under: owner decision for GEN2-AUTHORITY-001 + +## Context + +The existing Creator session endpoints correctly require a human principal for +governed Cognitive CANARY and ACTIVE transitions. A bounded background task +cannot use those endpoints without either a browser session, impersonation, or +a direct Capability write. All three alternatives are invalid. + +## Decision + +1. Identity remains the sole owner of Actors, Principals and their bindings. A + service principal is a separately identified Actor/Principal binding; it is + neither a Human Principal nor an impersonation of one. +2. Authority remains the sole owner of delegated task grants, validation, + revocation and safe action audit. This extends `AuthorityGrant`; it does not + introduce a robot-authority owner or a second authorization registry. +3. A delegated grant is typed and narrow: service principal, task id and + revision, operation capability, exact Cognitive binding resource/revision, + issuer, issue/expiry times, grant revision and lifecycle status. It is + default-deny and cannot authorize grant creation, extension, policy change, + or administration. +4. A control-plane request is authorized in the existing `commit-runtime`, the + runtime topology location of Authority. It must pass two independent checks: service credential + authentication and a current delegated-grant authorization for its exact + task/operation/resource. Credential possession never expands authority. +5. Long-lived credential material remains only in the existing protected secret + backend and is addressed by a `CredentialRef` plus bootstrap file reference. + The runtime never logs, persists, or returns its token. Rotation changes the + protected artifact; revocation of a grant blocks new actions immediately. +6. Capability remains the only owner of Cognitive binding/evaluation lifecycle. + The delegated path invokes the existing owner validation and promotion + builders; it cannot synthesize an evaluation or mark a binding active. +7. Control-plane actions receive durable Authority audit records containing + identity, task, operation, resource, decision and safe result reference. + Secret values, request payloads, user memory and hidden reasoning are never + stored in that audit. +8. External mutations continue to use Commit/Executor/Receipt/Outcome where + the existing action is an external effect. Capability's owner-local binding + lifecycle remains an owner-local governed state transition, not a shortcut + around an external executor. + +## Consequences + +- Background work can execute only an explicitly delegated, time-bounded task. +- Restart reconstructs authorization from Identity/Authority durable state; no + process-local permission cache is authoritative. +- Each later task must receive a new delegated grant. A grant for + GEN2-COGNITIVE-003 cannot be reused for SELF development or code promotion. +- The first implementation deliberately covers only the Cognitive lifecycle + operations required by GEN2-COGNITIVE-003. Broader control-plane actions + need their own task-scoped authority and acceptance evidence. + +## Rollback + +Disable or revoke the delegated grant and route no new non-interactive requests +to the endpoint. Existing Authority audit and Capability history remain +append-only evidence; no binding state is rolled back by deleting history. diff --git a/cmd/commit-runtime/control_plane_authority.go b/cmd/commit-runtime/control_plane_authority.go new file mode 100644 index 0000000..461db6f --- /dev/null +++ b/cmd/commit-runtime/control_plane_authority.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" + "time" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" + secretcontracts "git.cin.su/m/Assistant/contracts/secrets" + authoritydomain "git.cin.su/m/Assistant/modules/authority/domain" + "git.cin.su/m/Assistant/platform/ids" + platformsecrets "git.cin.su/m/Assistant/platform/secrets" +) + +const ( + delegatedControlPlaneActor identity.ActorID = "domovoy-control-plane-actor" + delegatedControlPlanePrincipal identity.PrincipalID = "domovoy-control-plane-principal" + delegatedControlPlaneTokenPath = "/run/secrets/control_plane_service_token" +) + +type delegatedControlPlaneAuthoritySource interface { + LoadDelegatedControlPlaneGrant(context.Context, authority.GrantID) (authority.DelegatedControlPlaneGrant, error) + RecordDelegatedControlPlaneAudit(context.Context, authority.DelegatedControlPlaneAudit) error + RevokeDelegatedControlPlaneGrant(context.Context, authority.GrantID, string) (bool, error) +} + +type delegatedControlPlaneCore interface { + Session(context.Context, string) (identity.CreatorSession, error) + Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error) +} + +type delegatedControlPlaneHandler struct { + authority delegatedControlPlaneAuthoritySource + core delegatedControlPlaneCore + credential func() (string, error) + now func() time.Time + newID func() (ids.ID, error) +} + +func registerDelegatedControlPlaneRoutes(mux *http.ServeMux, handler delegatedControlPlaneHandler) { + mux.HandleFunc("POST /v1/internal/control-plane/authorize", handler.authorize) + mux.HandleFunc("POST /v1/internal/control-plane/completions", handler.complete) + mux.HandleFunc("POST /v1/creator/control-plane/grants/{grant}/revoke", handler.revoke) +} + +func (h delegatedControlPlaneHandler) authorize(w http.ResponseWriter, r *http.Request) { + if !h.serviceAuthenticated(r) { + http.Error(w, "service principal denied", http.StatusUnauthorized) + return + } + var request authority.DelegatedControlPlaneRequest + decoder := json.NewDecoder(io.LimitReader(r.Body, 8<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&request); err != nil { + http.Error(w, "invalid delegated control-plane request", http.StatusBadRequest) + return + } + grant, err := h.authority.LoadDelegatedControlPlaneGrant(r.Context(), request.GrantID) + if err != nil { + http.Error(w, "delegated authority denied", http.StatusForbidden) + return + } + binding, err := h.core.Binding(r.Context(), delegatedControlPlaneActor, delegatedControlPlanePrincipal) + if err != nil || authoritydomain.ValidateDelegatedControlPlaneGrant(h.now().UnixMilli(), binding, grant, request) != nil { + _ = h.audit(r.Context(), grant, request, "DENY", "FAILED") + http.Error(w, "delegated authority denied", http.StatusForbidden) + return + } + if err := h.audit(r.Context(), grant, request, "ALLOW", "PENDING"); err != nil { + http.Error(w, "delegated audit unavailable", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h delegatedControlPlaneHandler) complete(w http.ResponseWriter, r *http.Request) { + if !h.serviceAuthenticated(r) { + http.Error(w, "service principal denied", http.StatusUnauthorized) + return + } + var value authority.DelegatedControlPlaneCompletion + decoder := json.NewDecoder(io.LimitReader(r.Body, 8<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil || (value.Outcome != "COMPLETED" && value.Outcome != "FAILED") { + http.Error(w, "invalid delegated completion", http.StatusBadRequest) + return + } + grant, err := h.authority.LoadDelegatedControlPlaneGrant(r.Context(), value.Request.GrantID) + if err != nil { + http.Error(w, "delegated authority unavailable", http.StatusServiceUnavailable) + return + } + if !authoritydomain.RequestMatchesDelegatedControlPlaneGrant(grant, value.Request) { + http.Error(w, "delegated completion denied", http.StatusForbidden) + return + } + if err := h.audit(r.Context(), grant, value.Request, "ALLOW", value.Outcome); err != nil { + http.Error(w, "delegated audit unavailable", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h delegatedControlPlaneHandler) revoke(w http.ResponseWriter, r *http.Request) { + session, err := h.core.Session(r.Context(), r.Header.Get("Authorization")) + if err != nil { + http.Error(w, "creator session denied", http.StatusUnauthorized) + return + } + grant, err := h.authority.LoadDelegatedControlPlaneGrant(r.Context(), authority.GrantID(r.PathValue("grant"))) + if err != nil || !authoritydomain.CanIssuerRevokeDelegatedGrant(session.PrincipalID, grant) { + http.Error(w, "delegated grant revoke denied", http.StatusForbidden) + return + } + ok, err := h.authority.RevokeDelegatedControlPlaneGrant(r.Context(), grant.ID, string(session.PrincipalID)) + if err != nil || !ok { + http.Error(w, "delegated grant revoke denied", http.StatusConflict) + return + } + request := authority.DelegatedControlPlaneRequest{GrantID: grant.ID, TaskID: grant.TaskID, TaskRevision: grant.TaskRevision, Operation: authority.DelegatedOperationRevokeControlPlaneGrant, Resource: grant.Resource} + if err := h.audit(r.Context(), grant, request, "REVOKE", "REVOKED"); err != nil { + http.Error(w, "delegated audit unavailable", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h delegatedControlPlaneHandler) serviceAuthenticated(r *http.Request) bool { + if h.credential == nil { + return false + } + expected, err := h.credential() + presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + return err == nil && expected != "" && presented != "" && subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1 +} + +func (h delegatedControlPlaneHandler) audit(ctx context.Context, grant authority.DelegatedControlPlaneGrant, request authority.DelegatedControlPlaneRequest, decision, outcome string) error { + if h.newID == nil { + return errors.New("delegated audit id unavailable") + } + id, err := h.newID() + if err != nil { + return err + } + return h.authority.RecordDelegatedControlPlaneAudit(ctx, authority.DelegatedControlPlaneAudit{AuditID: id, GrantID: grant.ID, ServiceActorID: delegatedControlPlaneActor, ServicePrincipalID: delegatedControlPlanePrincipal, TaskID: request.TaskID, TaskRevision: request.TaskRevision, Operation: request.Operation, Resource: request.Resource, Decision: decision, Outcome: outcome, RecordedAtUnixMillis: h.now().UnixMilli()}) +} + +func delegatedControlPlaneCredential() (string, error) { + ref := os.Getenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF") + if ref == "" { + return "", errors.New("control-plane service credential ref is required") + } + root := os.Getenv("SECRET_BACKEND_ROOT") + if root == "" { + root = "/run/secrets" + } + resolver, err := platformsecrets.NewFileResolver(root) + if err != nil { + return "", err + } + return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumer: secretcontracts.RuntimeConsumerCommit, Path: delegatedControlPlaneTokenPath}) +} diff --git a/cmd/commit-runtime/control_plane_authority_test.go b/cmd/commit-runtime/control_plane_authority_test.go new file mode 100644 index 0000000..0c38840 --- /dev/null +++ b/cmd/commit-runtime/control_plane_authority_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/platform/ids" +) + +type delegatedAuthorityFake struct { + grant authority.DelegatedControlPlaneGrant + audits []authority.DelegatedControlPlaneAudit + revoked bool +} + +func (f *delegatedAuthorityFake) LoadDelegatedControlPlaneGrant(context.Context, authority.GrantID) (authority.DelegatedControlPlaneGrant, error) { + if f.grant.ID == "" { + return authority.DelegatedControlPlaneGrant{}, errors.New("missing") + } + return f.grant, nil +} +func (f *delegatedAuthorityFake) RecordDelegatedControlPlaneAudit(_ context.Context, audit authority.DelegatedControlPlaneAudit) error { + f.audits = append(f.audits, audit) + return nil +} +func (f *delegatedAuthorityFake) RevokeDelegatedControlPlaneGrant(context.Context, authority.GrantID, string) (bool, error) { + f.revoked = true + f.grant.Status = authority.DelegatedGrantRevoked + return true, nil +} + +type delegatedCoreFake struct{} + +func (delegatedCoreFake) Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error) { + return identity.PrincipalBinding{ActorID: delegatedControlPlaneActor, PrincipalID: delegatedControlPlanePrincipal, Active: true}, nil +} +func (delegatedCoreFake) Session(context.Context, string) (identity.CreatorSession, error) { + return identity.CreatorSession{PrincipalID: "owner"}, nil +} + +func TestDelegatedAuthorityRejectsWrongResourceAndSurvivesRestart(t *testing.T) { + resource := authority.CognitiveBindingResource{BindingID: "binding", ProviderInstanceID: "provider", ModelDescriptorID: "model"} + fake := &delegatedAuthorityFake{grant: authority.DelegatedControlPlaneGrant{ID: "grant", ServiceActorID: delegatedControlPlaneActor, ServicePrincipalID: delegatedControlPlanePrincipal, IssuerPrincipalID: "owner", TaskID: "GEN2-COGNITIVE-003", TaskRevision: 4, Operations: []authority.DelegatedControlPlaneOperation{authority.DelegatedOperationCanaryCognitiveBinding}, Resource: resource, IssuedAtUnixMillis: 1, ExpiresAtUnixMillis: 100, ArchitectureRevision: "ADR-0021", Nonce: "nonce", Revision: 1, Status: authority.DelegatedGrantActive}} + h := delegatedControlPlaneHandler{authority: fake, core: delegatedCoreFake{}, credential: func() (string, error) { return "service-token", nil }, now: func() time.Time { return time.UnixMilli(10) }, newID: func() (ids.ID, error) { return "audit", nil }} + mux := http.NewServeMux() + registerDelegatedControlPlaneRoutes(mux, h) + valid := `{"GrantID":"grant","TaskID":"GEN2-COGNITIVE-003","TaskRevision":4,"Operation":"CANARY_COGNITIVE_BINDING","Resource":{"BindingID":"binding","ProviderInstanceID":"provider","ModelDescriptorID":"model"}}` + request := httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", strings.NewReader(valid)) + request.Header.Set("Authorization", "Bearer service-token") + response := httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("valid delegation status=%d", response.Code) + } + wrong := strings.Replace(valid, `"binding"`, `"other"`, 1) + request = httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", strings.NewReader(wrong)) + request.Header.Set("Authorization", "Bearer service-token") + response = httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("wrong resource status=%d", response.Code) + } + // A fresh handler reads the same durable grant source; no process-local allow survives restart. + request = httptest.NewRequest(http.MethodPost, "/v1/creator/control-plane/grants/grant/revoke", nil) + request.Header.Set("Authorization", "Bearer creator-session") + response = httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusNoContent || !fake.revoked { + t.Fatalf("revoke status=%d revoked=%v", response.Code, fake.revoked) + } + restarted := delegatedControlPlaneHandler{authority: fake, core: delegatedCoreFake{}, credential: h.credential, now: h.now, newID: h.newID} + mux = http.NewServeMux() + registerDelegatedControlPlaneRoutes(mux, restarted) + request = httptest.NewRequest(http.MethodPost, "/v1/internal/control-plane/authorize", strings.NewReader(valid)) + request.Header.Set("Authorization", "Bearer service-token") + response = httptest.NewRecorder() + mux.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("revoked grant accepted after restart: %d", response.Code) + } + if len(fake.audits) < 3 { + t.Fatalf("audit trail missing: %#v", fake.audits) + } +} diff --git a/cmd/commit-runtime/main.go b/cmd/commit-runtime/main.go index 6813628..77a8d7f 100644 --- a/cmd/commit-runtime/main.go +++ b/cmd/commit-runtime/main.go @@ -100,6 +100,9 @@ func main() { core: creatorCore, continuity: creatorContinuity, authority: authorityRepository, repository: repository, executor: dispatcher, newID: ids.New, now: time.Now, }, executorToken) + registerDelegatedControlPlaneRoutes(mux, delegatedControlPlaneHandler{ + authority: authorityRepository, core: creatorCore, credential: delegatedControlPlaneCredential, now: time.Now, newID: ids.New, + }) mux.HandleFunc("POST /v1/authorized-actions", func(w http.ResponseWriter, r *http.Request) { var request authorizationRequest if err := json.NewDecoder(r.Body).Decode(&request); err != nil { diff --git a/cmd/core-runtime/control_plane_authority.go b/cmd/core-runtime/control_plane_authority.go new file mode 100644 index 0000000..55fb6e1 --- /dev/null +++ b/cmd/core-runtime/control_plane_authority.go @@ -0,0 +1,76 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "git.cin.su/m/Assistant/contracts/authority" +) + +var ErrControlPlaneUnavailable = errors.New("delegated control-plane authority unavailable") + +type controlPlaneAuthority struct { + baseURL string + client *http.Client + credential func() (string, error) +} + +func (a controlPlaneAuthority) authorize(ctx context.Context, request authority.DelegatedControlPlaneRequest) error { + return a.post(ctx, "/v1/internal/control-plane/authorize", request) +} + +func (a controlPlaneAuthority) completed(ctx context.Context, request authority.DelegatedControlPlaneRequest, success bool) { + outcome := "FAILED" + if success { + outcome = "COMPLETED" + } + _ = a.post(ctx, "/v1/internal/control-plane/completions", authority.DelegatedControlPlaneCompletion{Request: request, Outcome: outcome}) +} + +func (a controlPlaneAuthority) revoke(ctx context.Context, creatorAuthorization string, grantID authority.GrantID) error { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.baseURL, "/")+"/v1/creator/control-plane/grants/"+string(grantID)+"/revoke", nil) + if err != nil { + return err + } + request.Header.Set("Authorization", creatorAuthorization) + response, err := a.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusNoContent { + return fmt.Errorf("delegated grant revoke status %d", response.StatusCode) + } + return nil +} + +func (a controlPlaneAuthority) post(ctx context.Context, path string, value any) error { + credential, err := a.credential() + if err != nil || credential == "" { + return ErrControlPlaneUnavailable + } + body, err := json.Marshal(value) + if err != nil { + return err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.baseURL, "/")+path, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Authorization", "Bearer "+credential) + request.Header.Set("Content-Type", "application/json") + response, err := a.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("delegated control-plane status %d", response.StatusCode) + } + return nil +} diff --git a/cmd/core-runtime/control_plane_authority_test.go b/cmd/core-runtime/control_plane_authority_test.go new file mode 100644 index 0000000..16d8031 --- /dev/null +++ b/cmd/core-runtime/control_plane_authority_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "git.cin.su/m/Assistant/contracts/authority" +) + +func TestControlPlaneClientSendsOnlyServiceCredentialToAuthorityRuntime(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/internal/control-plane/authorize" || r.Header.Get("Authorization") != "Bearer service-token" { + t.Fatalf("unexpected delegated request: %s %q", r.URL.Path, r.Header.Get("Authorization")) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + client := controlPlaneAuthority{baseURL: server.URL, client: server.Client(), credential: func() (string, error) { return "service-token", nil }} + request := authority.DelegatedControlPlaneRequest{GrantID: "grant", TaskID: "task", TaskRevision: 1, Operation: authority.DelegatedOperationCanaryCognitiveBinding, Resource: authority.CognitiveBindingResource{BindingID: "binding", ProviderInstanceID: "provider", ModelDescriptorID: "model"}} + if err := client.authorize(context.Background(), request); err != nil { + t.Fatalf("authorize: %v", err) + } +} diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 767df7f..ccfde22 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -39,12 +39,13 @@ import ( ) const ( - creatorPasswordPath = "/run/secrets/creator_password" - creatorBootstrapTokenPath = "/run/secrets/creator_bootstrap_token" - creatorSessionKeyPath = "/run/secrets/creator_session_signing_key" - epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" - interactionDBPasswordPath = "/run/secrets/interaction_db_password" - selfDBPasswordPath = "/run/secrets/self_db_password" + creatorPasswordPath = "/run/secrets/creator_password" + creatorBootstrapTokenPath = "/run/secrets/creator_bootstrap_token" + creatorSessionKeyPath = "/run/secrets/creator_session_signing_key" + epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" + interactionDBPasswordPath = "/run/secrets/interaction_db_password" + selfDBPasswordPath = "/run/secrets/self_db_password" + controlPlaneServiceTokenPath = "/run/secrets/control_plane_service_token" ) const maxCreatorCredentialsBytes = 4 << 10 @@ -137,6 +138,14 @@ func main() { } else if selfUser == "" || os.Getenv("SELF_POSTGRES_CREDENTIAL_REF") == "" { log.Printf("SELF learning repository is not configured; user responses remain unaffected") } + var delegatedAuthority *controlPlaneAuthority + if os.Getenv("CONTROL_PLANE_SERVICE_CREDENTIAL_REF") != "" { + delegatedAuthority = &controlPlaneAuthority{baseURL: envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"), client: &http.Client{Timeout: 5 * time.Second}, credential: func() (string, error) { + return readCredentialRefSecretPurpose("CONTROL_PLANE_SERVICE_TOKEN", "CONTROL_PLANE_SERVICE_CREDENTIAL_REF", controlPlaneServiceTokenPath, secretcontracts.SecretPurposeServiceAuthentication) + }} + } else { + log.Printf("delegated control-plane authority is not configured; existing human control remains available") + } reminderClient, err := newContinuityReminderClient(envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"), &http.Client{Timeout: 5 * time.Second}) if err != nil { log.Fatal(err) @@ -1669,6 +1678,119 @@ func main() { w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(promotion) }) + mux.HandleFunc("POST /v1/control-plane/cognitive-canary", func(w http.ResponseWriter, r *http.Request) { + if delegatedAuthority == nil { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + return + } + var value struct { + Delegation authority.DelegatedControlPlaneRequest `json:"delegation"` + Promotion capability.CognitiveCanaryPromotionRequest `json:"promotion"` + } + decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil || value.Delegation.Operation != authority.DelegatedOperationCanaryCognitiveBinding { + http.Error(w, "invalid delegated canary request", http.StatusBadRequest) + return + } + current, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), value.Promotion.BindingID, value.Promotion.ExpectedBindingRevision) + if err != nil { + http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable) + return + } + resource := authority.CognitiveBindingResource{BindingID: string(current.Binding.ID), ProviderInstanceID: string(current.Provider.ID), ModelDescriptorID: string(current.Model.ID)} + if value.Delegation.Resource != resource { + http.Error(w, "delegated resource denied", http.StatusForbidden) + return + } + err = delegatedAuthority.authorize(r.Context(), value.Delegation) + if err != nil { + if errors.Is(err, ErrControlPlaneUnavailable) { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + } else { + http.Error(w, "delegated control-plane authority denied", http.StatusForbidden) + } + return + } + value.Promotion.RequestedByPrincipalID = current.Binding.OwnerPrincipalID + value.Promotion.CapabilityID = current.Binding.CapabilityID + promotion, err := capabilitydomain.BuildCanaryPromotion(value.Promotion, current) + if err == nil { + err = capabilityRepository.SaveCognitiveCanaryPromotion(r.Context(), promotion) + } + delegatedAuthority.completed(r.Context(), value.Delegation, err == nil) + if err != nil { + http.Error(w, "canary promotion precondition failed", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(promotion) + }) + mux.HandleFunc("POST /v1/control-plane/cognitive-activate", func(w http.ResponseWriter, r *http.Request) { + if delegatedAuthority == nil { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + return + } + var value struct { + Delegation authority.DelegatedControlPlaneRequest `json:"delegation"` + Promotion capability.CognitiveActivationPromotionRequest `json:"promotion"` + } + decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil || value.Delegation.Operation != authority.DelegatedOperationActivateCognitiveBinding { + http.Error(w, "invalid delegated activation request", http.StatusBadRequest) + return + } + current, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), value.Promotion.BindingID, value.Promotion.ExpectedBindingRevision) + if err != nil { + http.Error(w, "cognitive registry unavailable", http.StatusServiceUnavailable) + return + } + resource := authority.CognitiveBindingResource{BindingID: string(current.Binding.ID), ProviderInstanceID: string(current.Provider.ID), ModelDescriptorID: string(current.Model.ID)} + if value.Delegation.Resource != resource { + http.Error(w, "delegated resource denied", http.StatusForbidden) + return + } + err = delegatedAuthority.authorize(r.Context(), value.Delegation) + if err != nil { + if errors.Is(err, ErrControlPlaneUnavailable) { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + } else { + http.Error(w, "delegated control-plane authority denied", http.StatusForbidden) + } + return + } + value.Promotion.RequestedByPrincipalID = current.Binding.OwnerPrincipalID + value.Promotion.CapabilityID = current.Binding.CapabilityID + promotion, err := capabilitydomain.BuildActivationPromotion(value.Promotion, current) + if err == nil { + err = capabilityRepository.SaveCognitiveActivationPromotion(r.Context(), promotion) + } + delegatedAuthority.completed(r.Context(), value.Delegation, err == nil) + if err != nil { + http.Error(w, "cognitive activation precondition failed", http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(promotion) + }) + mux.HandleFunc("POST /v1/creator/control-plane/grants/{grant}/revoke", func(w http.ResponseWriter, r *http.Request) { + if delegatedAuthority == nil { + http.Error(w, "delegated control-plane authority unavailable", http.StatusServiceUnavailable) + return + } + _, ok := authenticateCreator(w, r) + if !ok { + return + } + if err := delegatedAuthority.revoke(r.Context(), r.Header.Get("Authorization"), authority.GrantID(r.PathValue("grant"))); err != nil { + http.Error(w, "delegated grant revoke denied", http.StatusForbidden) + return + } + w.WriteHeader(http.StatusNoContent) + }) mux.HandleFunc("POST /v1/cognitive-registry/performance-evidence", func(w http.ResponseWriter, r *http.Request) { var value capability.PerformanceEvidence decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) @@ -1963,6 +2085,10 @@ func normalizeSecretFile(name string, contents []byte) (string, error) { } func readCredentialRefSecret(name, refName, fileName string) (string, error) { + return readCredentialRefSecretPurpose(name, refName, fileName, secretcontracts.SecretPurposeDatabasePassword) +} + +func readCredentialRefSecretPurpose(name, refName, fileName string, purpose secretcontracts.SecretPurpose) (string, error) { if _, directSet := os.LookupEnv(name); directSet { return "", fmt.Errorf("%s direct environment secret is forbidden; use the standard secret file", name) } @@ -1980,7 +2106,7 @@ func readCredentialRefSecret(name, refName, fileName string) (string, error) { } return resolver.ResolveFile(secretcontracts.SecretFileRef{ CredentialRef: secretcontracts.CredentialRef(ref), - Purpose: secretcontracts.SecretPurposeDatabasePassword, + Purpose: purpose, Consumer: secretcontracts.RuntimeConsumerCore, Path: fileName, }) diff --git a/contracts/authority/control_plane.go b/contracts/authority/control_plane.go new file mode 100644 index 0000000..eb16c01 --- /dev/null +++ b/contracts/authority/control_plane.go @@ -0,0 +1,14 @@ +package authority + +import "git.cin.su/m/Assistant/platform/ids" + +// DelegatedControlPlaneAuthorization is the safe, payload-free decision that +// the Authority runtime returns to an already bounded caller. +type DelegatedControlPlaneAuthorization struct { + AuditID ids.ID +} + +type DelegatedControlPlaneCompletion struct { + Request DelegatedControlPlaneRequest + Outcome string +} diff --git a/contracts/authority/types.go b/contracts/authority/types.go index 2be4ee9..3be8c34 100644 --- a/contracts/authority/types.go +++ b/contracts/authority/types.go @@ -13,6 +13,73 @@ type GrantClass string const GrantClassCreator GrantClass = "CREATOR" +// DelegatedControlPlaneOperation is deliberately a closed, semantic operation +// set. A service credential cannot turn into arbitrary control-plane access. +type DelegatedControlPlaneOperation string + +const ( + DelegatedOperationQualifyCognitiveBinding DelegatedControlPlaneOperation = "QUALIFY_COGNITIVE_BINDING" + DelegatedOperationEvaluateCognitiveBinding DelegatedControlPlaneOperation = "EVALUATE_COGNITIVE_BINDING" + DelegatedOperationCanaryCognitiveBinding DelegatedControlPlaneOperation = "CANARY_COGNITIVE_BINDING" + DelegatedOperationActivateCognitiveBinding DelegatedControlPlaneOperation = "ACTIVATE_VALIDATED_COGNITIVE_BINDING" + DelegatedOperationRevokeControlPlaneGrant DelegatedControlPlaneOperation = "REVOKE_DELEGATED_CONTROL_PLANE_GRANT" +) + +type DelegatedGrantStatus string + +const ( + DelegatedGrantActive DelegatedGrantStatus = "ACTIVE" + DelegatedGrantRevoked DelegatedGrantStatus = "REVOKED" +) + +// CognitiveBindingResource identifies the one governed Capability lifecycle +// resource to which a task delegation applies. It is intentionally not a +// general-purpose resource selector. +type CognitiveBindingResource struct { + BindingID string + ProviderInstanceID string + ModelDescriptorID string +} + +type DelegatedControlPlaneGrant struct { + ID GrantID + ServiceActorID identity.ActorID + ServicePrincipalID identity.PrincipalID + IssuerPrincipalID identity.PrincipalID + TaskID string + TaskRevision int64 + Operations []DelegatedControlPlaneOperation + Resource CognitiveBindingResource + IssuedAtUnixMillis int64 + ExpiresAtUnixMillis int64 + ArchitectureRevision string + Nonce string + Revision AuthorityRevision + Status DelegatedGrantStatus +} + +type DelegatedControlPlaneRequest struct { + GrantID GrantID + TaskID string + TaskRevision int64 + Operation DelegatedControlPlaneOperation + Resource CognitiveBindingResource +} + +type DelegatedControlPlaneAudit struct { + AuditID ids.ID + GrantID GrantID + ServiceActorID identity.ActorID + ServicePrincipalID identity.PrincipalID + TaskID string + TaskRevision int64 + Operation DelegatedControlPlaneOperation + Resource CognitiveBindingResource + Decision string + Outcome string + RecordedAtUnixMillis int64 +} + type Scope struct { ID ScopeID Global bool diff --git a/contracts/secrets/types.go b/contracts/secrets/types.go index 57946d2..7115d55 100644 --- a/contracts/secrets/types.go +++ b/contracts/secrets/types.go @@ -8,8 +8,9 @@ type CredentialRef string type SecretPurpose string const ( - SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD" - SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY" + SecretPurposeDatabasePassword SecretPurpose = "DATABASE_PASSWORD" + SecretPurposeProviderAPIKey SecretPurpose = "PROVIDER_API_KEY" + SecretPurposeServiceAuthentication SecretPurpose = "SERVICE_AUTHENTICATION" ) // RuntimeConsumer identifies a trusted runtime allowed to resolve a credential. @@ -17,6 +18,7 @@ type RuntimeConsumer string const ( RuntimeConsumerCore RuntimeConsumer = "core-runtime" + RuntimeConsumerCommit RuntimeConsumer = "commit-runtime" RuntimeConsumerWorkWorker RuntimeConsumer = "agent-worker" RuntimeConsumerEpistemicsWorker RuntimeConsumer = "reflection-worker" RuntimeConsumerCognitive RuntimeConsumer = "cognitive-worker" diff --git a/database/migrations/056_delegated_control_plane_authority.sql b/database/migrations/056_delegated_control_plane_authority.sql new file mode 100644 index 0000000..6f7cd4c --- /dev/null +++ b/database/migrations/056_delegated_control_plane_authority.sql @@ -0,0 +1,36 @@ +BEGIN; + +SET ROLE identity_role; +INSERT INTO identity.actors (actor_id, display_name) VALUES ('domovoy-control-plane-actor', 'Domovoy Control Plane') ON CONFLICT (actor_id) DO NOTHING; +INSERT INTO identity.principals (principal_id, display_name) VALUES ('domovoy-control-plane-principal', 'Domovoy Control Plane Principal') ON CONFLICT (principal_id) DO NOTHING; +INSERT INTO identity.principal_bindings (actor_id, principal_id, active) VALUES ('domovoy-control-plane-actor', 'domovoy-control-plane-principal', TRUE) ON CONFLICT (actor_id, principal_id) DO NOTHING; +RESET ROLE; + +SET ROLE authority_role; +CREATE TABLE IF NOT EXISTS authority.delegated_control_plane_grants ( + grant_id TEXT PRIMARY KEY, service_actor_id TEXT NOT NULL, service_principal_id TEXT NOT NULL, issuer_principal_id TEXT NOT NULL, + task_id TEXT NOT NULL, task_revision BIGINT NOT NULL, operations TEXT[] NOT NULL, + binding_id TEXT NOT NULL, provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL, + issued_at_unix_millis BIGINT NOT NULL, expires_at_unix_millis BIGINT NOT NULL, architecture_revision TEXT NOT NULL, nonce TEXT NOT NULL, + revision BIGINT NOT NULL, status TEXT NOT NULL, revoked_at_unix_millis BIGINT NOT NULL DEFAULT 0, + CHECK (task_revision > 0), CHECK (cardinality(operations) > 0), + CHECK (operations <@ ARRAY['QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING']::TEXT[]), + CHECK (issued_at_unix_millis > 0), CHECK (expires_at_unix_millis > issued_at_unix_millis), CHECK (revision > 0), + CHECK (status IN ('ACTIVE','REVOKED')), CHECK (binding_id <> '' AND provider_instance_id <> '' AND model_descriptor_id <> ''), + CHECK (nonce <> '' AND architecture_revision <> '') +); +CREATE TABLE IF NOT EXISTS authority.delegated_control_plane_audit ( + audit_id TEXT PRIMARY KEY, grant_id TEXT NOT NULL, service_actor_id TEXT NOT NULL, service_principal_id TEXT NOT NULL, + task_id TEXT NOT NULL, task_revision BIGINT NOT NULL, operation TEXT NOT NULL, + binding_id TEXT NOT NULL, provider_instance_id TEXT NOT NULL, model_descriptor_id TEXT NOT NULL, + decision TEXT NOT NULL, outcome TEXT NOT NULL, recorded_at_unix_millis BIGINT NOT NULL, + CHECK (task_revision > 0), CHECK (recorded_at_unix_millis > 0), + CHECK (operation IN ('QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING','REVOKE_DELEGATED_CONTROL_PLANE_GRANT')), + CHECK (decision IN ('ALLOW','DENY','REVOKE')), CHECK (outcome IN ('PENDING','COMPLETED','FAILED','REVOKED')) +); +CREATE INDEX IF NOT EXISTS delegated_control_plane_audit_grant_idx ON authority.delegated_control_plane_audit (grant_id, recorded_at_unix_millis); +INSERT INTO authority.delegated_control_plane_grants (grant_id,service_actor_id,service_principal_id,issuer_principal_id,task_id,task_revision,operations,binding_id,provider_instance_id,model_descriptor_id,issued_at_unix_millis,expires_at_unix_millis,architecture_revision,nonce,revision,status) +VALUES ('gen2-cognitive-003-control-plane-grant','domovoy-control-plane-actor','domovoy-control-plane-principal','domovoy-creator-principal','GEN2-COGNITIVE-003',4,ARRAY['QUALIFY_COGNITIVE_BINDING','EVALUATE_COGNITIVE_BINDING','CANARY_COGNITIVE_BINDING','ACTIVATE_VALIDATED_COGNITIVE_BINDING']::TEXT[],'binding-c1be4c9f85d8b4a19314f2b8','local-medium-primary','model-26d63a1102b49c5583ffca53',1788134400000,1789344000000,'ADR-0021','gen2-cognitive-003-local-medium-v1',1,'ACTIVE') ON CONFLICT (grant_id) DO NOTHING; +GRANT SELECT, INSERT, UPDATE ON authority.delegated_control_plane_grants, authority.delegated_control_plane_audit TO authority_role; +RESET ROLE; +COMMIT; diff --git a/deploy/docker-compose.candidate-slot.yml b/deploy/docker-compose.candidate-slot.yml index 2571d38..3d38a7f 100644 --- a/deploy/docker-compose.candidate-slot.yml +++ b/deploy/docker-compose.candidate-slot.yml @@ -53,6 +53,7 @@ services: WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} + CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1 SELF_POSTGRES_USER: self_role SELF_POSTGRES_CREDENTIAL_REF: credential://domovoy/self/db-password/v1 EPISTEMICS_POSTGRES_USER: epistemics_role diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index e122546..cefb78d 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -70,6 +70,7 @@ services: WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role CAPABILITY_POSTGRES_PASSWORD: ${CAPABILITY_DB_PASSWORD} + CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1 SELF_POSTGRES_USER: self_role SELF_POSTGRES_CREDENTIAL_REF: credential://domovoy/self/db-password/v1 EPISTEMICS_POSTGRES_USER: epistemics_role @@ -239,6 +240,8 @@ services: POSTGRES_PASSWORD: ${COMMIT_DB_PASSWORD} AUTHORITY_POSTGRES_USER: authority_role AUTHORITY_POSTGRES_PASSWORD: ${AUTHORITY_DB_PASSWORD} + CONTROL_PLANE_SERVICE_CREDENTIAL_REF: credential://domovoy/control-plane/service-token/v1 + SECRET_BACKEND_ROOT: /run/secrets CORE_RUNTIME_URL: http://core-runtime:8080 CONTINUITY_RUNTIME_URL: http://continuity-runtime:8081 COMMIT_EXECUTOR_TOKEN: ${COMMIT_EXECUTOR_TOKEN} @@ -247,6 +250,7 @@ services: # External mutation credentials belong here only via Portainer/Docker secrets/env injection. depends_on: [core-runtime, continuity-runtime, notification-receiver] volumes: + - assistant_core_secrets:/run/secrets:ro - assistant_notification_writer_secret:/run/notification-writer:ro cognitive-worker: diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 355aafd..291676a 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json","task_id":"GEN2-COGNITIVE-003","task_revision":3} +{"task_file":"dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json","task_id":"GEN2-AUTHORITY-001","task_revision":3} diff --git a/dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json b/dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json new file mode 100644 index 0000000..17477bf --- /dev/null +++ b/dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json @@ -0,0 +1,28 @@ +{ + "task_id": "GEN2-AUTHORITY-001", + "task_revision": 3, + "architecture_revision": "ARCH-GEN2-000", + "base_repo_revision": "gen2-integration", + "risk_tier": "E", + "intent": "Add a bounded, non-interactive service-principal and task-scoped delegated Authority path for existing governed Cognitive binding lifecycle operations without browser impersonation, a second Authority owner, or direct Capability mutation.", + "requirement_ids": ["REQ-GEN2-AUTHORITY-001-DELEGATED-CONTROL-PLANE", "REQ-GEN2-COGNITIVE-003-Q8-Q17"], + "allowed_change_scope": { + "modules": ["Identity service principal bootstrap", "Authority delegated grant validation and audit", "Core composition and typed control-plane endpoint", "existing Capability lifecycle invocation", "production secret-reference wiring", "targeted evidence"], + "paths": ["architecture/adrs/ADR-0021-delegated-noninteractive-control-plane-authority.md", "contracts/authority/types.go", "contracts/authority/control_plane.go", "contracts/authority/*_test.go", "contracts/secrets/types.go", "modules/authority/domain/**", "modules/authority/application/**", "modules/authority/ports/**", "adapters/postgres/authority_repository.go", "adapters/postgres/authority_repository_test.go", "cmd/core-runtime/main.go", "cmd/core-runtime/control_plane_authority.go", "cmd/core-runtime/control_plane_authority_test.go", "cmd/commit-runtime/main.go", "cmd/commit-runtime/control_plane_authority.go", "cmd/commit-runtime/control_plane_authority_test.go", "database/migrations/056_delegated_control_plane_authority.sql", "deploy/docker-compose.prod.yml", "deploy/docker-compose.candidate-slot.yml", "docs/CREDENTIALS_AND_SECRETS.md", "docs/evidence/**", "docs/PROJECT_STATUS.md", "development/GEN2_TASK_INDEX.md", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-AUTHORITY-001-delegated-noninteractive-control-plane-authority.json"] + }, + "forbidden_change_scope": { + "paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "cmd/devctl/**", "contracts/identity/**", "modules/identity/**", "adapters/postgres/identity_repository.go", "contracts/capability/**", "modules/capability/**", "adapters/postgres/capability_repository.go", "cmd/cognitive-worker/**", "database/bootstrap/**", "security/**", "go.mod", "go.sum", "UI", "V1 retirement", "GEN2-EVOLVE-005", "Gen3/**"] + }, + "allowed_architecture_delta": {"new_cross_module_dependencies": 1, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 1}, + "contracts": {"consume": ["existing Identity Actor/PrincipalBinding", "existing Capability cognitive promotion requests/builders", "existing CredentialRef file resolver"], "may_change": ["typed Authority delegated-grant and audit contract", "typed secret purpose for service authentication"]}, + "semantic_owner": "Identity remains the sole owner of service identity. Authority remains the sole owner of delegated grant, validation, revocation and audit. Capability remains the sole owner of cognitive binding/evaluation lifecycle.", + "canonical_state": ["service authentication is distinct from delegated authority", "delegation is task/capability/resource/time scoped and default-deny", "service principal cannot issue, extend, revoke or broaden its own authority", "Capability activation continues to require independent evaluation and owner-local lifecycle validation", "audit contains no secret or user payload"], + "state_transitions": ["service credential authentication -> delegated grant validation -> existing Capability CANARY/ACTIVE validation -> owner-local lifecycle persistence -> Authority audit", "grant ACTIVE -> REVOKED/EXPIRED denies new privileged actions while retaining audit history"], + "side_effects_and_privileges": ["Core receives only an Authority CredentialRef and service authentication SecretRef through the protected file backend", "the positive new_privileges and new_database_owner_roles values are devctl compatibility gates for deploy/database surfaces; no new role, broad administrator privilege, or ownership change is granted", "no external effect bypasses Commit/Executor where those owners already govern it"], + "persistence_and_migrations": ["additive owner-local Authority tables only; seed exactly one bounded service identity and GEN2-COGNITIVE-003 grant through owner roles", "historical audit is retained on rollback"], + "failure_retry_restart": ["authentication or validation failure is fail-closed", "grant state is read durably on each request", "no in-memory permission cache", "revocation prevents new actions immediately", "existing Capability revision preconditions prevent replay or duplicate promotion"], + "assumptions": ["the protected assistant_core_secrets control plane can materialize a non-empty control-plane service credential without exposing its value", "the current production schema has migrations through 055", "the owner decision authorizes a bounded GEN2-COGNITIVE-003 grant only, not future task grants"], + "acceptance_scenarios": ["valid grant permits only its declared Cognitive lifecycle operation and binding revision", "different binding, missing grant, expired grant and revoked grant are denied", "service principal cannot create or extend grants", "restart uses durable grant state", "audit is recorded without payload/secret", "browser session is not required", "existing independent Capability evaluation remains a mandatory promotion precondition"], + "required_tests": ["targeted go test", "targeted go vet", "migration/compose validation", "preflight", "check-diff", "independent", "architecture/security MILESTONE", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"] +} diff --git a/development/GEN2_TASK_INDEX.md b/development/GEN2_TASK_INDEX.md index a4ea4a0..6d3f956 100644 --- a/development/GEN2_TASK_INDEX.md +++ b/development/GEN2_TASK_INDEX.md @@ -48,4 +48,10 @@ - **M6 retirement:** `GEN2-029` — FULL before irreversible retirement. - **M6 final:** `GEN2-030` — FULL final acceptance. +## Bounded post-M5 remediation + +| Task | Gate | Depends on | Purpose | +|---|---|---|---| +| `GEN2-AUTHORITY-001` | `MILESTONE` | owner decision, existing Identity/Authority/Capability lifecycle | Bounded non-interactive service principal and task-scoped control-plane delegation; does not retire V1 or change Gen2 canonical ownership. | + Между реперными точками выполняется только FAST-профиль конкретного packet-а. diff --git a/docs/CREDENTIALS_AND_SECRETS.md b/docs/CREDENTIALS_AND_SECRETS.md index 2409200..97043cf 100644 --- a/docs/CREDENTIALS_AND_SECRETS.md +++ b/docs/CREDENTIALS_AND_SECRETS.md @@ -23,6 +23,8 @@ Each privileged runtime gets only its required secret/capability bindings. `comm The Core SELF learning repository uses the existing `assistant_core_secrets` volume and credential-reference mechanism with `self_role` and `credential://domovoy/self/db-password/v1`. The secret is consumed from the fixed `/run/secrets/self_db_password` path; its value is never placed in Compose environment, Git, logs or ordinary configuration. The role and schema already exist; this wiring does not create a role or broaden privileges. +Delegated non-interactive control-plane work is separately authenticated. The external `assistant_core_secrets` control plane must materialize `control_plane_service_token`, mode `0400`, for `core-runtime` and the existing Authority/Commit runtime only. Compose carries only `credential://domovoy/control-plane/service-token/v1`; trusted runtimes resolve the fixed file below `/run/secrets`. The service token is not a Creator session, cannot impersonate a human, and must be rotated or withdrawn in the secret backend. Removing the token disables new delegated operations; revoking the durable Authority grant also blocks them without deleting audit history. + Creator authentication secrets are stored in the external Docker volume `assistant_core_secrets` and mounted read-only only at `/run/secrets` in `core-runtime`. The production container environment contains `_FILE` paths, not the secret values. Files are owned by runtime uid 100/gid 101 with mode 0400. `CREATOR_USERNAME` is not secret and remains an environment value. Development may continue to use direct environment values, but a direct value and its matching `_FILE` setting must never be configured together. The PostgreSQL off-host backup passphrase is generated randomly and stored as CurrentUser DPAPI ciphertext at `%LOCALAPPDATA%\Domovoy\postgres-backup-key.dpapi.json`. An atomic copy of that DPAPI ciphertext, never the plaintext passphrase, is retained at `\\nas\MST\codex\_backups\domovoy\keys\postgres-backup-key.dpapi.json`. The passphrase is never stored in Git, the production Docker host, a scheduled-task argument, or NAS plaintext. The NAS backup directory receives only GnuPG AES-256 ciphertext and a non-secret integrity manifest. Recovery therefore requires both the NAS artifacts and the authorized Windows DPAPI profile. diff --git a/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md b/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md new file mode 100644 index 0000000..b1dfc9f --- /dev/null +++ b/docs/evidence/gen2-authority-001-secret-capability-gap-2026-08-31.md @@ -0,0 +1,40 @@ +# GEN2-AUTHORITY-001 — protected service credential prerequisite + +Date: 2026-08-31 + +The existing `assistant_core_secrets` volume was inspected by filename only. +It contains the established Creator, Epistemics, Interaction, Local gateway, +SELF and Work artifacts, but no `control_plane_service_token` artifact. + +No secret value was read, copied, printed or inferred. The implementation is +fail-closed: Core and `commit-runtime` remain healthy while the delegated path +returns unavailable/denied until the external secret control plane materializes +the credential reference `credential://domovoy/control-plane/service-token/v1` +at `/run/secrets/control_plane_service_token` for those two trusted runtimes. + +This is a `BLOCKED_BY_MISSING_CAPABILITY` for live non-interactive acceptance, +not a reason to use a Creator session, password, browser cookie, SQL update, or +broader service authority. + +## Implemented and verified locally + +The bounded implementation is present on branch +`gen2/authority-001-delegated-control-plane`. It keeps Identity as the service +identity owner, Authority as the durable grant/revocation/audit owner, and +Capability as the cognitive lifecycle owner. Core is an Authority-runtime +client; the Authority endpoint is served by the existing Commit runtime that +already owns the Authority repository in the V2 runtime topology. + +On 2026-08-31 the following checks completed successfully without secret +values: + +- targeted `go test` for Authority contracts/domain, secret resolver, Core, + Commit runtime, and the Authority PostgreSQL adapter; +- targeted `go vet` for the same packages; +- production and candidate Compose static validation; +- task preflight, diff scope, independent governance, and architecture checks; +- `git diff --check`. + +The only missing external capability is protected materialization of +`control_plane_service_token` for Core and Commit runtime. No deployment or +privileged lifecycle request was attempted while it is absent. diff --git a/modules/authority/domain/delegated_control_plane.go b/modules/authority/domain/delegated_control_plane.go new file mode 100644 index 0000000..c5530d7 --- /dev/null +++ b/modules/authority/domain/delegated_control_plane.go @@ -0,0 +1,34 @@ +package domain + +import ( + "errors" + "slices" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" +) + +var ErrDelegatedAuthorityDenied = errors.New("delegated control-plane authority denied") + +// ValidateDelegatedControlPlaneGrant proves only the Authority-owned decision. +// It does not replace the receiving semantic owner's lifecycle preconditions. +func ValidateDelegatedControlPlaneGrant(nowUnixMillis int64, binding identity.PrincipalBinding, grant authority.DelegatedControlPlaneGrant, request authority.DelegatedControlPlaneRequest) error { + if nowUnixMillis < 1 || binding.ActorID == "" || binding.PrincipalID == "" || !binding.Active || + grant.ID == "" || grant.Revision < 1 || grant.Status != authority.DelegatedGrantActive || + grant.ServiceActorID != binding.ActorID || grant.ServicePrincipalID != binding.PrincipalID || + grant.IssuerPrincipalID == "" || grant.ArchitectureRevision == "" || grant.Nonce == "" || + grant.IssuedAtUnixMillis < 1 || grant.ExpiresAtUnixMillis <= grant.IssuedAtUnixMillis || nowUnixMillis < grant.IssuedAtUnixMillis || nowUnixMillis >= grant.ExpiresAtUnixMillis || !RequestMatchesDelegatedControlPlaneGrant(grant, request) { + return ErrDelegatedAuthorityDenied + } + return nil +} + +func RequestMatchesDelegatedControlPlaneGrant(grant authority.DelegatedControlPlaneGrant, request authority.DelegatedControlPlaneRequest) bool { + return grant.TaskID != "" && grant.TaskID == request.TaskID && grant.TaskRevision > 0 && grant.TaskRevision == request.TaskRevision && + request.GrantID == grant.ID && request.Operation != "" && slices.Contains(grant.Operations, request.Operation) && + grant.Resource.BindingID != "" && grant.Resource.ProviderInstanceID != "" && grant.Resource.ModelDescriptorID != "" && grant.Resource == request.Resource +} + +func CanIssuerRevokeDelegatedGrant(issuer identity.PrincipalID, grant authority.DelegatedControlPlaneGrant) bool { + return issuer != "" && issuer == grant.IssuerPrincipalID && grant.Status == authority.DelegatedGrantActive +} diff --git a/modules/authority/domain/delegated_control_plane_test.go b/modules/authority/domain/delegated_control_plane_test.go new file mode 100644 index 0000000..2685805 --- /dev/null +++ b/modules/authority/domain/delegated_control_plane_test.go @@ -0,0 +1,43 @@ +package domain + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" +) + +func delegatedGrant() authority.DelegatedControlPlaneGrant { + return authority.DelegatedControlPlaneGrant{ID: "grant-1", ServiceActorID: "service-actor", ServicePrincipalID: "service-principal", IssuerPrincipalID: "owner", TaskID: "GEN2-COGNITIVE-003", TaskRevision: 4, Operations: []authority.DelegatedControlPlaneOperation{authority.DelegatedOperationCanaryCognitiveBinding}, Resource: authority.CognitiveBindingResource{BindingID: "binding-1", ProviderInstanceID: "provider-1", ModelDescriptorID: "model-1"}, IssuedAtUnixMillis: 10, ExpiresAtUnixMillis: 100, ArchitectureRevision: "ADR-0021", Nonce: "nonce", Revision: 1, Status: authority.DelegatedGrantActive} +} + +func TestDelegatedControlPlaneGrantIsExactAndTimeBound(t *testing.T) { + grant := delegatedGrant() + binding := identity.PrincipalBinding{ActorID: "service-actor", PrincipalID: "service-principal", Active: true} + request := authority.DelegatedControlPlaneRequest{GrantID: grant.ID, TaskID: grant.TaskID, TaskRevision: grant.TaskRevision, Operation: authority.DelegatedOperationCanaryCognitiveBinding, Resource: grant.Resource} + if err := ValidateDelegatedControlPlaneGrant(20, binding, grant, request); err != nil { + t.Fatalf("valid delegation denied: %v", err) + } + request.Resource.BindingID = "other" + if err := ValidateDelegatedControlPlaneGrant(20, binding, grant, request); err == nil { + t.Fatal("different resource was allowed") + } + request.Resource = grant.Resource + if err := ValidateDelegatedControlPlaneGrant(100, binding, grant, request); err == nil { + t.Fatal("expired grant was allowed") + } + grant.Status = authority.DelegatedGrantRevoked + if err := ValidateDelegatedControlPlaneGrant(20, binding, grant, request); err == nil { + t.Fatal("revoked grant was allowed") + } +} + +func TestDelegatedGrantCannotSelfRevokeOrBroaden(t *testing.T) { + grant := delegatedGrant() + if CanIssuerRevokeDelegatedGrant("service-principal", grant) { + t.Fatal("service principal may not revoke its own grant") + } + if !CanIssuerRevokeDelegatedGrant("owner", grant) { + t.Fatal("issuer could not revoke active grant") + } +}