diff --git a/adapters/postgres/identity_repository.go b/adapters/postgres/identity_repository.go index 65ae6d7..dd3dc76 100644 --- a/adapters/postgres/identity_repository.go +++ b/adapters/postgres/identity_repository.go @@ -62,3 +62,13 @@ func (r *IdentityRepository) UpdateCreatorPassword(ctx context.Context, actor id } return nil } + +func (r *IdentityRepository) LoadServicePrincipal(ctx context.Context, principal identity.PrincipalID) (identity.ServicePrincipal, error) { + var value identity.ServicePrincipal + err := r.pool.QueryRow(ctx, `SELECT actor_id, principal_id, kind, purpose, synthetic_scope, active, revision FROM identity.service_principals WHERE principal_id=$1`, principal). + Scan(&value.ActorID, &value.PrincipalID, &value.Kind, &value.Purpose, &value.SyntheticScope, &value.Active, &value.Revision) + if err != nil { + return identity.ServicePrincipal{}, fmt.Errorf("load service principal: %w", err) + } + return value, nil +} diff --git a/adapters/postgres/self_repository.go b/adapters/postgres/self_repository.go index e5d6624..f5c205a 100644 --- a/adapters/postgres/self_repository.go +++ b/adapters/postgres/self_repository.go @@ -13,6 +13,13 @@ import ( // evidence. It never evaluates, promotes or changes Capability state. type SelfRepository struct{ pool *pgxpool.Pool } +type AcceptanceSummary struct { + LearningSignals int `json:"learning_signals"` + ImprovementNeeds int `json:"improvement_needs"` + LearningLedger int `json:"learning_ledger"` + SyntheticPrincipal string `json:"synthetic_principal"` +} + func OpenSelf(ctx context.Context, config ContinuityConfig) (*SelfRepository, error) { pool, err := openPool(ctx, config) if err != nil { @@ -23,6 +30,21 @@ func OpenSelf(ctx context.Context, config ContinuityConfig) (*SelfRepository, er func (r *SelfRepository) Close() { r.pool.Close() } +func (r *SelfRepository) AcceptanceSummary(ctx context.Context, principalID string) (AcceptanceSummary, error) { + var value AcceptanceSummary + value.SyntheticPrincipal = principalID + if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM self.learning_signals WHERE principal_id=$1`, principalID).Scan(&value.LearningSignals); err != nil { + return AcceptanceSummary{}, err + } + if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM self.improvement_needs WHERE principal_id=$1`, principalID).Scan(&value.ImprovementNeeds); err != nil { + return AcceptanceSummary{}, err + } + if err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM self.learning_ledger WHERE principal_id=$1`, principalID).Scan(&value.LearningLedger); err != nil { + return AcceptanceSummary{}, err + } + return value, nil +} + func (r *SelfRepository) SaveImprovementNeed(ctx context.Context, value selfcontract.ImprovementNeed) error { subject, err := json.Marshal(value.SubjectRef) if err != nil { diff --git a/cmd/auth-acceptance-harness/main.go b/cmd/auth-acceptance-harness/main.go new file mode 100644 index 0000000..f9063af --- /dev/null +++ b/cmd/auth-acceptance-harness/main.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/contracts/interaction" +) + +const tokenPath = "/run/secrets/acceptance_harness_service_token" + +type client struct { + base string + token string + http *http.Client +} + +func main() { + base := strings.TrimRight(os.Getenv("CORE_RUNTIME_URL"), "/") + if base == "" { + base = "http://core-runtime-candidate:8080" + } + token, err := os.ReadFile(tokenPath) + if err != nil { + fail("acceptance credential unavailable") + } + c := client{base: base, token: strings.TrimSpace(string(token)), http: &http.Client{Timeout: 90 * time.Second}} + if strings.TrimSpace(c.token) == "" { + fail("acceptance credential unavailable") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + var issued struct { + Token string + Session identity.CreatorSession + } + if err := c.post(ctx, "/v1/acceptance/sessions", nil, &issued); err != nil { + fail("acceptance authentication failed") + } + if issued.Token == "" || issued.Session.PrincipalKind != identity.PrincipalKindService { + fail("acceptance principal was not authenticated as service") + } + c.token = issued.Token + var first interaction.ChatMessageResponse + if err := c.post(ctx, "/v1/creator/chat/messages", interaction.ChatMessageRequest{Message: "synthetic acceptance statement", CorrelationID: "acceptance-harness", SyntheticAcceptance: true, SyntheticScopeID: "synthetic-acceptance-scope"}, &first); err != nil { + fail("synthetic interaction failed") + } + var corrected interaction.ChatMessageResponse + if err := c.post(ctx, "/v1/creator/chat/messages", interaction.ChatMessageRequest{ConversationID: first.ConversationID, Message: "synthetic acceptance correction", CorrelationID: "acceptance-harness-correction", CorrectsUtteranceID: first.UtteranceID, SyntheticAcceptance: true, SyntheticScopeID: "synthetic-acceptance-scope"}, &corrected); err != nil { + fail("synthetic correction failed") + } + var summary struct { + LearningSignals int `json:"learning_signals"` + ImprovementNeeds int `json:"improvement_needs"` + LearningLedger int `json:"learning_ledger"` + } + if err := c.get(ctx, "/v1/acceptance/self/summary", &summary); err != nil { + fail("acceptance SELF summary unavailable") + } + fmt.Printf("acceptance_pass learning_signals=%d improvement_needs=%d learning_ledger=%d\n", summary.LearningSignals, summary.ImprovementNeeds, summary.LearningLedger) +} + +func (c client) post(ctx context.Context, path string, body any, out any) error { + return c.request(ctx, http.MethodPost, path, body, out) +} +func (c client) get(ctx context.Context, path string, out any) error { + return c.request(ctx, http.MethodGet, path, nil, out) +} +func (c client) request(ctx context.Context, method, path string, body any, out any) error { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, c.base+path, reader) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return errors.New("acceptance request denied") + } + if out != nil && json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out) != nil { + return errors.New("invalid acceptance response") + } + return nil +} +func fail(message string) { fmt.Fprintln(os.Stderr, message); os.Exit(1) } diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 1815d3f..8ba842c 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -47,6 +47,7 @@ const ( epistemicsDBPasswordPath = "/run/secrets/epistemics_db_password" interactionDBPasswordPath = "/run/secrets/interaction_db_password" selfDBPasswordPath = "/run/secrets/self_db_password" + acceptanceHarnessTokenPath = "/run/secrets/acceptance_harness_service_token" controlPlaneServiceTokenFile = "control_plane_service_token" ) @@ -162,6 +163,18 @@ func main() { if err := bootstrapCreatorAccount(context.Background(), identityRepository, creatorUsername, creatorPasswordPath); err != nil { log.Fatal(err) } + acceptanceServiceToken := "" + var acceptancePrincipal identity.ServicePrincipal + if os.Getenv("ACCEPTANCE_HARNESS_SERVICE_CREDENTIAL_REF") != "" { + acceptanceServiceToken, err = readCredentialRefSecretPurpose("ACCEPTANCE_HARNESS_SERVICE_TOKEN", "ACCEPTANCE_HARNESS_SERVICE_CREDENTIAL_REF", acceptanceHarnessTokenPath, secretcontracts.SecretPurposeServiceAuthentication) + if err != nil { + log.Fatal(err) + } + acceptancePrincipal, err = identityRepository.LoadServicePrincipal(context.Background(), identity.PrincipalID("domovoy-acceptance-harness-principal")) + if err != nil || !acceptancePrincipal.Active { + log.Fatal("acceptance service principal is unavailable") + } + } interactionPassword, err := readCredentialRefSecret("INTERACTION_POSTGRES_PASSWORD", "INTERACTION_POSTGRES_CREDENTIAL_REF", interactionDBPasswordPath) if err != nil { log.Fatal(err) @@ -230,12 +243,32 @@ func main() { mux := http.NewServeMux() authenticateCreator := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) { session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now()) - if err != nil { + if err != nil || (session.PrincipalKind != "" && session.PrincipalKind != identity.PrincipalKindHuman) { http.Error(w, "creator session denied", http.StatusUnauthorized) return identity.CreatorSession{}, false } return session, true } + authenticateAcceptance := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) { + if acceptanceServiceToken == "" || acceptancePrincipal.Kind != identity.PrincipalKindService || !acceptancePrincipal.Active || !identitydomain.BootstrapMatches(acceptanceServiceToken, bearerToken(r)) { + http.Error(w, "acceptance authentication denied", http.StatusUnauthorized) + return identity.CreatorSession{}, false + } + _, session, err := identitydomain.IssueServiceSession(sessionKey, acceptancePrincipal, time.Now(), 30*time.Minute) + if err != nil { + http.Error(w, "acceptance session unavailable", http.StatusServiceUnavailable) + return identity.CreatorSession{}, false + } + return session, true + } + authenticateAcceptanceSession := func(w http.ResponseWriter, r *http.Request) (identity.CreatorSession, bool) { + session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now()) + if err != nil || session.PrincipalKind != identity.PrincipalKindService || session.PrincipalID != acceptancePrincipal.PrincipalID || !acceptancePrincipal.Active { + http.Error(w, "acceptance session denied", http.StatusUnauthorized) + return identity.CreatorSession{}, false + } + return session, true + } resolveFocus := func(r *http.Request) (situation.FocusBinding, error) { runtime := situation.RuntimeBinding{SessionID: situation.SessionID(r.URL.Query().Get("session_id")), Workspace: situation.WorkspaceID(r.URL.Query().Get("workspace_id"))} bindings, err := repository.ByRuntime(r.Context(), runtime) @@ -286,6 +319,35 @@ func main() { w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("POST /v1/creator/sessions", creatorDatabaseSessionHandler(bootstrapToken, creatorUsername, identityRepository, sessionKey, time.Now)) + mux.HandleFunc("POST /v1/acceptance/sessions", func(w http.ResponseWriter, r *http.Request) { + session, ok := authenticateAcceptance(w, r) + if !ok { + return + } + token, issued, err := identitydomain.IssueServiceSession(sessionKey, acceptancePrincipal, time.Now(), 30*time.Minute) + if err != nil { + http.Error(w, "acceptance session unavailable", http.StatusServiceUnavailable) + return + } + _ = session + writeCreatorSession(w, token, issued) + }) + mux.HandleFunc("GET /v1/acceptance/self/summary", func(w http.ResponseWriter, r *http.Request) { + if _, ok := authenticateAcceptanceSession(w, r); !ok { + return + } + if selfRepository == nil { + http.Error(w, "SELF unavailable", http.StatusServiceUnavailable) + return + } + summary, err := selfRepository.AcceptanceSummary(r.Context(), string(acceptancePrincipal.PrincipalID)) + if err != nil { + http.Error(w, "SELF summary unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(summary) + }) mux.HandleFunc("POST /v1/creator/account/password", func(w http.ResponseWriter, r *http.Request) { session, ok := authenticateCreator(w, r) if !ok { @@ -596,15 +658,21 @@ func main() { }{Experiences: experiences, Reflections: reflections, Skills: skills}) }) mux.HandleFunc("POST /v1/creator/chat/messages", func(w http.ResponseWriter, r *http.Request) { + var request interaction.ChatMessageRequest + body, readErr := io.ReadAll(http.MaxBytesReader(w, r.Body, interactiondomain.MaxHumanUtteranceBytes+1024)) + if readErr != nil || json.Unmarshal(body, &request) != nil { + http.Error(w, "invalid chat message", http.StatusBadRequest) + return + } session, ok := authenticateCreator(w, r) + if !ok && request.SyntheticAcceptance { + session, ok = authenticateAcceptanceSession(w, r) + } if !ok { return } - var request interaction.ChatMessageRequest - decoder := json.NewDecoder(io.LimitReader(r.Body, interactiondomain.MaxHumanUtteranceBytes+1024)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&request); err != nil || decoder.Decode(&struct{}{}) != io.EOF { - http.Error(w, "invalid chat message", http.StatusBadRequest) + if session.PrincipalKind == identity.PrincipalKindService && (!request.SyntheticAcceptance || request.SyntheticScopeID != acceptancePrincipal.SyntheticScope) { + http.Error(w, "synthetic acceptance scope denied", http.StatusForbidden) return } now := time.Now() diff --git a/cmd/secret-control-plane/main.go b/cmd/secret-control-plane/main.go index 50d232e..7b3ab0f 100644 --- a/cmd/secret-control-plane/main.go +++ b/cmd/secret-control-plane/main.go @@ -17,6 +17,7 @@ const ( func main() { reason := flag.String("reason", "", "rotation reason: owner-requested-rotation or exposure-detected") + credential := flag.String("credential", "control-plane", "credential purpose: control-plane or acceptance-harness") flag.Parse() if flag.NArg() != 1 { fail("usage: secret-control-plane [ensure|rotate]") @@ -26,6 +27,9 @@ func main() { fail("control-plane credential store unavailable") } spec := platformsecrets.ControlPlaneServiceTokenSpec() + if strings.TrimSpace(*credential) == "acceptance-harness" { + spec = platformsecrets.AcceptanceHarnessServiceTokenSpec() + } switch strings.TrimSpace(flag.Arg(0)) { case "ensure": result, err := store.Ensure(spec) diff --git a/contracts/identity/session.go b/contracts/identity/session.go index 68a1416..3919942 100644 --- a/contracts/identity/session.go +++ b/contracts/identity/session.go @@ -16,5 +16,23 @@ type CreatorAccount struct { type CreatorSession struct { ActorID ActorID PrincipalID PrincipalID + PrincipalKind PrincipalKind ExpiresAtUnix int64 } + +type PrincipalKind string + +const ( + PrincipalKindHuman PrincipalKind = "HUMAN" + PrincipalKindService PrincipalKind = "SERVICE" +) + +type ServicePrincipal struct { + ActorID ActorID + PrincipalID PrincipalID + Kind PrincipalKind + Purpose string + SyntheticScope string + Active bool + Revision int64 +} diff --git a/contracts/interaction/types.go b/contracts/interaction/types.go index a086c58..dae7ea5 100644 --- a/contracts/interaction/types.go +++ b/contracts/interaction/types.go @@ -215,6 +215,8 @@ type ChatMessageRequest struct { Message string CorrelationID string CorrectsUtteranceID HumanUtteranceID + SyntheticAcceptance bool + SyntheticScopeID string } type ChatMessageResponse struct { diff --git a/database/migrations/059_identity_acceptance_harness.sql b/database/migrations/059_identity_acceptance_harness.sql new file mode 100644 index 0000000..314e892 --- /dev/null +++ b/database/migrations/059_identity_acceptance_harness.sql @@ -0,0 +1,24 @@ +BEGIN; +SET ROLE identity_role; +CREATE TABLE IF NOT EXISTS identity.service_principals ( + principal_id TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind = 'SERVICE'), + purpose TEXT NOT NULL, + synthetic_scope TEXT NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + revision BIGINT NOT NULL DEFAULT 1, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (principal_id <> '' AND actor_id <> '' AND purpose <> '' AND synthetic_scope <> '' AND revision > 0) +); +CREATE UNIQUE INDEX IF NOT EXISTS service_principals_actor_idx ON identity.service_principals(actor_id); +INSERT INTO identity.service_principals(principal_id, actor_id, kind, purpose, synthetic_scope) +VALUES ('domovoy-acceptance-harness-principal', 'domovoy-acceptance-harness-actor', 'SERVICE', 'AUTHENTICATE_ACCEPTANCE_HARNESS', 'synthetic-acceptance-scope') +ON CONFLICT (principal_id) DO NOTHING; +INSERT INTO identity.actors(actor_id, display_name) VALUES ('domovoy-acceptance-harness-actor', 'Domovoy Acceptance Harness') ON CONFLICT (actor_id) DO NOTHING; +INSERT INTO identity.principals(principal_id, display_name) VALUES ('domovoy-acceptance-harness-principal', 'Domovoy Acceptance Harness Principal') ON CONFLICT (principal_id) DO NOTHING; +INSERT INTO identity.principal_bindings(actor_id, principal_id, active) VALUES ('domovoy-acceptance-harness-actor', 'domovoy-acceptance-harness-principal', TRUE) ON CONFLICT (actor_id, principal_id) DO UPDATE SET active=TRUE; +REVOKE ALL ON identity.service_principals FROM PUBLIC; +GRANT SELECT ON identity.service_principals TO identity_role; +RESET ROLE; +COMMIT; diff --git a/deploy/docker-compose.candidate-slot.yml b/deploy/docker-compose.candidate-slot.yml index f80cdae..02fa967 100644 --- a/deploy/docker-compose.candidate-slot.yml +++ b/deploy/docker-compose.candidate-slot.yml @@ -49,6 +49,7 @@ services: IDENTITY_POSTGRES_USER: identity_role IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD} CREATOR_USERNAME: ${CREATOR_USERNAME} + ACCEPTANCE_HARNESS_SERVICE_CREDENTIAL_REF: credential://domovoy/acceptance-harness/service-token/v1 WORK_POSTGRES_USER: work_role WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 52b5515..d5628f3 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -66,6 +66,7 @@ services: IDENTITY_POSTGRES_USER: identity_role IDENTITY_POSTGRES_PASSWORD: ${IDENTITY_DB_PASSWORD} CREATOR_USERNAME: ${CREATOR_USERNAME} + ACCEPTANCE_HARNESS_SERVICE_CREDENTIAL_REF: credential://domovoy/acceptance-harness/service-token/v1 WORK_POSTGRES_USER: work_role WORK_POSTGRES_PASSWORD: ${WORK_DB_PASSWORD} CAPABILITY_POSTGRES_USER: capability_role diff --git a/deploy/provision-machine-credential.sh b/deploy/provision-machine-credential.sh index 5acbb31..6d9c94b 100644 --- a/deploy/provision-machine-credential.sh +++ b/deploy/provision-machine-credential.sh @@ -9,6 +9,7 @@ ENV_FILE="${ENV_FILE:-$BASE_DIR/production-compose.env}" COMPOSE_FILE="$REPO_DIR/deploy/docker-compose.prod.yml" VOLUME="assistant_control_plane_service_secrets" ACTION="${1:-ensure}" +CREDENTIAL="${MACHINE_CREDENTIAL:-control-plane}" case "$ACTION" in ensure|rotate) ;; @@ -32,7 +33,7 @@ if [[ "$ACTION" == "rotate" ]]; then owner-requested-rotation|exposure-detected) ;; *) echo "invalid rotation reason" >&2; exit 2 ;; esac - ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane -reason "$reason" rotate + ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane -credential "$CREDENTIAL" -reason "$reason" rotate else - ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane ensure + ASSISTANT_SOURCE_REVISION="$revision" compose --profile secret-control-plane run --rm --no-deps secret-control-plane -credential "$CREDENTIAL" ensure fi diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index feef746..9e8dd1a 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-EVOLVE-004-RUNTIME-001-wire-self-learning-loop.json","task_id":"GEN2-EVOLVE-004-RUNTIME-001","task_revision":2} +{"task_file":"dev/tasks/GEN2-AUTH-HARNESS-001-first-party-authenticated-service-acceptance.json","task_id":"GEN2-AUTH-HARNESS-001","task_revision":1} diff --git a/dev/tasks/GEN2-AUTH-HARNESS-001-first-party-authenticated-service-acceptance.json b/dev/tasks/GEN2-AUTH-HARNESS-001-first-party-authenticated-service-acceptance.json new file mode 100644 index 0000000..9915594 --- /dev/null +++ b/dev/tasks/GEN2-AUTH-HARNESS-001-first-party-authenticated-service-acceptance.json @@ -0,0 +1,26 @@ +{ + "task_id": "GEN2-AUTH-HARNESS-001", + "task_revision": 1, + "architecture_revision": "ARCH-GEN2-000", + "base_repo_revision": "gen2-integration", + "risk_tier": "E", + "intent": "Provide a reusable first-party authenticated service acceptance harness through the existing Identity/Auth boundary for synthetic, non-user interaction scenarios, without owner impersonation, auth bypass, a second Identity owner, or direct database access.", + "requirement_ids": ["REQ-GEN2-AUTH-HARNESS-001-SERVICE-AUTH", "REQ-GEN2-EVOLVE-004-RUNTIME-001-DURABLE-LEARNING"], + "allowed_change_scope": { + "modules": ["Identity service-principal lifecycle", "authenticated service-session validation", "synthetic acceptance scope enforcement", "Core interaction acceptance read/query", "protected machine-credential provisioning", "typed acceptance harness", "targeted evidence"], + "paths": ["contracts/identity/**", "contracts/interaction/**", "contracts/self/**", "modules/identity/**", "modules/interaction/**", "modules/self/**", "adapters/postgres/identity_repository.go", "adapters/postgres/interaction_repository.go", "adapters/postgres/self_repository.go", "cmd/core-runtime/**", "cmd/auth-acceptance-harness/**", "platform/secrets/**", "cmd/secret-control-plane/**", "database/migrations/059_identity_acceptance_harness.sql", "deploy/docker-compose.prod.yml", "deploy/docker-compose.candidate-slot.yml", "deploy/provision-machine-credential.sh", "deploy/provision-machine-credential_test.sh", "docs/CREDENTIALS_AND_SECRETS.md", "docs/evidence/**", "docs/PROJECT_STATUS.md", "development/GEN2_TASK_INDEX.md", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-AUTH-HARNESS-001-first-party-authenticated-service-acceptance.json"] + }, + "forbidden_change_scope": {"paths": ["architecture/ARCHITECTURE_CONSTITUTION.md", "architecture/architecture-registry.yaml", "architecture/architecture-registry.json", "cmd/devctl/**", "cmd/cognitive-worker/**", "contracts/authority/**", "modules/authority/**", "cmd/commit-runtime/**", "database/bootstrap/**", "UI", "V1 retirement", "GEN2-EVOLVE-005", "Gen3/**", "go.mod", "go.sum"]}, + "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 Creator session validation boundary", "existing Interaction and SELF repositories", "existing protected SecretRef/FileResolver"], "may_change": ["typed service-principal authentication metadata", "typed synthetic acceptance operations", "typed canonical SELF acceptance query summary"]}, + "semantic_owner": "Identity remains the sole owner of service identity and authentication. Interaction remains the owner of synthetic interactions. SELF remains the sole owner of learning evidence. The harness is only a bounded authenticated client.", + "canonical_state": ["service principal is distinct from the Creator and cannot access Creator data", "synthetic resources are typed and isolated by principal and scope", "credential value exists only in protected secret artifacts", "authentication does not grant Authority", "SELF evidence retains synthetic provenance and cannot promote production competence automatically"], + "state_transitions": ["ABSENT -> ACTIVE service principal via idempotent Identity bootstrap", "credential ABSENT -> ACTIVE via existing protected control plane", "authenticated synthetic request -> bounded Interaction path -> learning evidence", "service credential revoked/expired -> DENY while audit/history remain"], + "side_effects_and_privileges": ["only the existing protected secret control plane may provision the dedicated harness credential", "Core resolves only its SecretRef through the protected read-only mount", "synthetic acceptance cannot mutate production Authority, owner memory, or production competence", "no browser cookies, owner password, token value, raw prompt or user memory is read or emitted"], + "persistence_and_migrations": ["one additive Identity-owned service-principal/credential metadata migration is permitted only if existing schema cannot represent it", "SELF and Interaction canonical evidence/history are retained", "no direct database write is performed by the harness"], + "failure_retry_restart": ["missing, wrong, expired and revoked credentials deny closed", "service principal and synthetic scope are loaded durably, not from process-local state", "SELF write failure is diagnostic and non-fatal to an already prepared response", "bounded scenarios have explicit operation and wall-time limits", "restart must preserve canonical evidence and typed summaries"], + "assumptions": ["the existing protected secret control plane can provision a dedicated credential without exposing its value", "the candidate ReleaseSet can mount the protected volume read-only", "the current Identity/Auth boundary can be extended without impersonating the Creator"], + "acceptance_scenarios": ["valid harness credential authenticates through normal middleware", "missing/wrong/revoked/expired auth is denied", "harness cannot read owner data", "synthetic interaction reaches the real Core learning path", "correction and verified failure persist through SelfRepository", "canonical SELF summary is readable after restart", "synthetic provenance is preserved and does not auto-promote production competence", "credential rotation remains black-box and old credential is denied", "candidate revision is verified and stable remains untouched"], + "required_tests": ["targeted go test", "targeted go vet", "compose/config validation", "auth harness regression", "preflight", "check-diff", "independent", "architecture/governance 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 9d7d7a9..cc479ce 100644 --- a/development/GEN2_TASK_INDEX.md +++ b/development/GEN2_TASK_INDEX.md @@ -59,5 +59,6 @@ Bounded post-M5 remediation. Provides the technical protected-secret control-pla | `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. | | `GEN2-DEPLOY-004` | `MILESTONE` | GEN2-DEPLOY-003, GEN2-AUTHORITY-001 implementation | Extend the isolated candidate slot and typed Supervisor ReleaseSet with Commit runtime before delegated Authority acceptance; stable containers are never recreated by candidate operations. | | `GEN2-CAPABILITY-001` | `MILESTONE` | owner architecture decision, GEN2-AUTHORITY-001 implementation | Capability-owned release-scoped candidate/shadow binding lifecycle for dynamic candidate dependency closure; stable ACTIVE bindings remain immutable. | +| `GEN2-AUTH-HARNESS-001` | `MILESTONE` | owner decision, GEN2-EVOLVE-004-RUNTIME-001 implementation | First-party authenticated service acceptance through the normal Identity boundary; synthetic scope only, no owner impersonation or auth bypass. | Между реперными точками выполняется только FAST-профиль конкретного packet-а. diff --git a/modules/identity/domain/session.go b/modules/identity/domain/session.go index 3fa7429..a4c11fd 100644 --- a/modules/identity/domain/session.go +++ b/modules/identity/domain/session.go @@ -23,7 +23,23 @@ func IssueCreatorSessionForBinding(secret []byte, actorID identity.ActorID, prin if len(secret) < 32 || actorID == "" || principalID == "" || ttl <= 0 { return "", identity.CreatorSession{}, ErrInvalidCreatorSession } - session := identity.CreatorSession{ActorID: actorID, PrincipalID: principalID, ExpiresAtUnix: now.Add(ttl).Unix()} + session := identity.CreatorSession{ActorID: actorID, PrincipalID: principalID, PrincipalKind: identity.PrincipalKindHuman, ExpiresAtUnix: now.Add(ttl).Unix()} + payload, err := json.Marshal(session) + if err != nil { + return "", identity.CreatorSession{}, err + } + encoded := base64.RawURLEncoding.EncodeToString(payload) + return encoded + "." + signature(secret, encoded), session, nil +} + +func IssueServiceSession(secret []byte, principal identity.ServicePrincipal, now time.Time, ttl time.Duration) (string, identity.CreatorSession, error) { + if principal.Kind != identity.PrincipalKindService || !principal.Active || principal.ActorID == "" || principal.PrincipalID == "" || principal.Purpose == "" || ttl <= 0 { + return "", identity.CreatorSession{}, ErrInvalidCreatorSession + } + if len(secret) < 32 { + return "", identity.CreatorSession{}, ErrInvalidCreatorSession + } + session := identity.CreatorSession{ActorID: principal.ActorID, PrincipalID: principal.PrincipalID, PrincipalKind: principal.Kind, ExpiresAtUnix: now.Add(ttl).Unix()} payload, err := json.Marshal(session) if err != nil { return "", identity.CreatorSession{}, err @@ -45,6 +61,9 @@ func ValidateCreatorSession(secret []byte, token string, now time.Time) (identit if json.Unmarshal(payload, &session) != nil || session.ActorID == "" || session.PrincipalID == "" || now.Unix() >= session.ExpiresAtUnix { return identity.CreatorSession{}, ErrInvalidCreatorSession } + if session.PrincipalKind == "" { + session.PrincipalKind = identity.PrincipalKindHuman + } return session, nil } diff --git a/modules/identity/domain/session_test.go b/modules/identity/domain/session_test.go new file mode 100644 index 0000000..8f7d696 --- /dev/null +++ b/modules/identity/domain/session_test.go @@ -0,0 +1,36 @@ +package domain + +import ( + "bytes" + "testing" + "time" + + "git.cin.su/m/Assistant/contracts/identity" +) + +func TestServiceSessionIsDistinctFromHumanCreatorSession(t *testing.T) { + secret := bytes.Repeat([]byte{7}, 32) + principal := identity.ServicePrincipal{ActorID: "acceptance-actor", PrincipalID: "acceptance-principal", Kind: identity.PrincipalKindService, Purpose: "AUTHENTICATE_ACCEPTANCE_HARNESS", SyntheticScope: "synthetic-acceptance-scope", Active: true, Revision: 1} + token, issued, err := IssueServiceSession(secret, principal, time.Unix(100, 0), time.Minute) + if err != nil { + t.Fatal(err) + } + if issued.PrincipalKind != identity.PrincipalKindService { + t.Fatalf("kind=%q", issued.PrincipalKind) + } + validated, err := ValidateCreatorSession(secret, token, time.Unix(101, 0)) + if err != nil { + t.Fatal(err) + } + if validated.PrincipalKind != identity.PrincipalKindService || validated.PrincipalID != principal.PrincipalID { + t.Fatalf("validated=%#v", validated) + } +} + +func TestServiceSessionRejectsInactivePrincipal(t *testing.T) { + secret := bytes.Repeat([]byte{7}, 32) + _, _, err := IssueServiceSession(secret, identity.ServicePrincipal{ActorID: "a", PrincipalID: "p", Kind: identity.PrincipalKindService, Purpose: "test"}, time.Unix(100, 0), time.Minute) + if err == nil { + t.Fatal("inactive service principal was accepted") + } +} diff --git a/platform/secrets/machine_credentials.go b/platform/secrets/machine_credentials.go index 8b34b71..40bbcc5 100644 --- a/platform/secrets/machine_credentials.go +++ b/platform/secrets/machine_credentials.go @@ -18,6 +18,7 @@ import ( const ( ControlPlaneServiceTokenID = "control_plane_service_token" + AcceptanceHarnessServiceTokenID = "acceptance_harness_service_token" MachineCredentialOwner = "platform.secrets.local-control-plane" ) @@ -127,6 +128,10 @@ func ControlPlaneServiceTokenSpec() MachineCredentialSpec { } } +func AcceptanceHarnessServiceTokenSpec() MachineCredentialSpec { + return MachineCredentialSpec{ID: AcceptanceHarnessServiceTokenID, Kind: MachineCredentialServiceToken, Purpose: secretcontracts.SecretPurposeServiceAuthentication, Consumers: []secretcontracts.RuntimeConsumer{secretcontracts.RuntimeConsumerCore}, Bytes: 48} +} + func (s *LocalMachineCredentialStore) Ensure(spec MachineCredentialSpec) (MachineCredentialResult, error) { if err := validateMachineCredentialSpec(spec); err != nil { return MachineCredentialResult{}, err