diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index dc5e400..78c077e 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -46,6 +46,22 @@ func main() { } defer identityRepository.Close() 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 { + http.Error(w, "creator 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) + if err != nil { + return situation.FocusBinding{}, err + } + return situationdomain.NewResolver(bindings).Resolve(runtime) + } mux.Handle("/healthz", health.Handler{Runtime: "core-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1}) mux.HandleFunc("POST /v1/creator/sessions", func(w http.ResponseWriter, r *http.Request) { if !identitydomain.BootstrapMatches(bootstrapToken, bearerToken(r)) { @@ -64,14 +80,64 @@ func main() { }{token, session}) }) mux.HandleFunc("GET /v1/creator/sessions/current", func(w http.ResponseWriter, r *http.Request) { - session, err := identitydomain.ValidateCreatorSession(sessionKey, bearerToken(r), time.Now()) - if err != nil { - http.Error(w, "creator session denied", http.StatusUnauthorized) + session, ok := authenticateCreator(w, r) + if !ok { return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(session) }) + mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) { + session, ok := authenticateCreator(w, r) + if !ok { + return + } + focus, err := resolveFocus(r) + if err != nil { + http.Error(w, "creator focus unavailable", http.StatusConflict) + return + } + if focus.ActorID != session.ActorID { + http.Error(w, "creator focus denied", http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(focus) + }) + mux.HandleFunc("GET /v1/creator/tasks/{id}", func(w http.ResponseWriter, r *http.Request) { + session, ok := authenticateCreator(w, r) + if !ok { + return + } + focus, err := resolveFocus(r) + if err != nil || focus.ActorID != session.ActorID { + http.Error(w, "creator focus denied", http.StatusForbidden) + return + } + target, err := workRepository.Load(r.Context(), work.TaskID(r.PathValue("id"))) + if err != nil { + http.Error(w, "task not found", http.StatusNotFound) + return + } + if err := situationdomain.ValidateTaskTarget(focus, target); err != nil { + http.Error(w, "task outside creator focus", http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(target) + }) + mux.HandleFunc("GET /v1/creator/capabilities/{id}/health", func(w http.ResponseWriter, r *http.Request) { + if _, ok := authenticateCreator(w, r); !ok { + return + } + status, err := capabilityRepository.LoadHealth(r.Context(), capability.CapabilityID(r.PathValue("id"))) + if err != nil { + http.Error(w, "capability health not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(status) + }) mux.HandleFunc("GET /v1/principal-bindings/{actor}/{principal}", func(w http.ResponseWriter, r *http.Request) { binding, err := identityRepository.LoadBinding(r.Context(), identity.ActorID(r.PathValue("actor")), identity.PrincipalID(r.PathValue("principal"))) if err != nil { @@ -136,13 +202,7 @@ func main() { w.WriteHeader(http.StatusCreated) }) mux.HandleFunc("GET /v1/focus", func(w http.ResponseWriter, r *http.Request) { - 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) - if err != nil { - http.Error(w, "load focus", http.StatusInternalServerError) - return - } - focus, err := situationdomain.NewResolver(bindings).Resolve(runtime) + focus, err := resolveFocus(r) if err != nil { http.Error(w, err.Error(), http.StatusConflict) return diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go new file mode 100644 index 0000000..f6a3f39 --- /dev/null +++ b/cmd/core-runtime/main_test.go @@ -0,0 +1,18 @@ +package main + +import ( + "net/http/httptest" + "testing" +) + +func TestBearerTokenRequiresBearerScheme(t *testing.T) { + request := httptest.NewRequest("GET", "/", nil) + request.Header.Set("Authorization", "Basic value") + if token := bearerToken(request); token != "" { + t.Fatalf("unexpected token %q", token) + } + request.Header.Set("Authorization", "Bearer creator-session") + if token := bearerToken(request); token != "creator-session" { + t.Fatalf("token = %q", token) + } +} diff --git a/dev/tasks/DEV-000024-authenticated-creator-read-api.json b/dev/tasks/DEV-000024-authenticated-creator-read-api.json new file mode 100644 index 0000000..d828b08 --- /dev/null +++ b/dev/tasks/DEV-000024-authenticated-creator-read-api.json @@ -0,0 +1,15 @@ +{ + "task_id": "DEV-000024", + "task_revision": 1, + "architecture_revision": "bootstrap-1", + "base_repo_revision": "9b136621ef17982ae118705f9d0663dc014e5fa6", + "risk_tier": "C", + "intent": "Expose a session-authenticated, read-only Creator API for the Creator's own Focus, focus-matched Task, and Capability Health without granting mutation authority.", + "allowed_change_scope": {"paths": ["cmd/core-runtime/**", "tests/creator_api/**", "dev/tasks/DEV-000024-authenticated-creator-read-api.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "modules/commit/**", "contracts/**", "adapters/**", "deploy/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0}, + "semantic_owner": "none-read-composition", + "acceptance_scenarios": ["missing or invalid Creator session is denied", "Creator can read only a Focus bound to its Actor", "Task read must match the authenticated active Focus goal and scope", "Capability Health read does not mutate canonical state"], + "required_tests": ["unit", "runtime HTTP", "wrong-focus denial", "architecture"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP"] +} diff --git a/tests/creator_api/focus_access_test.go b/tests/creator_api/focus_access_test.go new file mode 100644 index 0000000..784750f --- /dev/null +++ b/tests/creator_api/focus_access_test.go @@ -0,0 +1,28 @@ +package creator_api_test + +import ( + "testing" + + "git.cin.su/m/Assistant/contracts/authority" + "git.cin.su/m/Assistant/contracts/identity" + "git.cin.su/m/Assistant/contracts/situation" + "git.cin.su/m/Assistant/contracts/work" + situationdomain "git.cin.su/m/Assistant/modules/situation/domain" +) + +func TestCreatorTaskReadRequiresOwnFocusTarget(t *testing.T) { + session := identity.CreatorSession{ActorID: "domovoy-creator-actor", PrincipalID: "domovoy-creator-principal"} + focus := situation.FocusBinding{ActorID: session.ActorID, GoalID: "goal-a", ScopeID: "scope-a", Active: true} + allowed := work.TaskTarget{TaskID: "task-a", GoalID: focus.GoalID, ScopeID: focus.ScopeID, Revision: 2} + if focus.ActorID != session.ActorID { + t.Fatal("creator focus actor mismatch") + } + if err := situationdomain.ValidateTaskTarget(focus, allowed); err != nil { + t.Fatal(err) + } + outside := allowed + outside.ScopeID = authority.ScopeID("scope-b") + if err := situationdomain.ValidateTaskTarget(focus, outside); err == nil { + t.Fatal("out-of-focus task accepted") + } +} diff --git a/tests/creator_api/verify-remote.ps1 b/tests/creator_api/verify-remote.ps1 new file mode 100644 index 0000000..8a158b6 --- /dev/null +++ b/tests/creator_api/verify-remote.ps1 @@ -0,0 +1,90 @@ +param([string]$DockerContext = "docker-prod") + +$ErrorActionPreference = "Stop" +$container = "assistant-core-runtime-1" +$baseURL = "http://localhost:8080" + +function Post-Json([string]$url, [object]$body) { + $json = $body | ConvertTo-Json -Depth 8 -Compress + $local = New-TemporaryFile + $remote = "/tmp/domovoy-$([guid]::NewGuid().ToString('N')).json" + try { + [IO.File]::WriteAllText($local, $json, [Text.UTF8Encoding]::new($false)) + docker --context $DockerContext cp $local "${container}:$remote" | Out-Null + $response = docker --context $DockerContext exec $container wget -qO- --header "Content-Type: application/json" --post-file $remote $url + if ($LASTEXITCODE -ne 0) { throw "POST $url failed" } + return $response + } finally { + docker --context $DockerContext exec --user root $container rm -f $remote 2>$null + Remove-Item -LiteralPath $local -Force + } +} + +function Get-Authenticated([string]$url, [string]$token) { + $response = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $container sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_SESSION" "$0"' $url + if ($LASTEXITCODE -ne 0) { throw "GET $url failed" } + return $response +} + +function Expect-GetStatus([string]$url, [int]$status, [string]$token = "") { + $previous = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $response = (docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $container sh -c 'wget -S -O /dev/null --header="Authorization: Bearer $CREATOR_TEST_SESSION" "$0"' $url 2>&1) -join "`n" + } finally { + $ErrorActionPreference = $previous + } + if ($response -notmatch "HTTP/1\.[01] $status") { throw "GET $url did not return $status" } +} + +for ($attempt = 0; $attempt -lt 15; $attempt++) { + docker --context $DockerContext exec $container wget -qO- "$baseURL/healthz" 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { break } + Start-Sleep -Seconds 2 +} +if ($LASTEXITCODE -ne 0) { throw "core runtime did not become healthy" } + +$environment = docker --context $DockerContext inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $container +$bootstrapEntry = $environment | Where-Object { $_ -like "CREATOR_BOOTSTRAP_TOKEN=*" } | Select-Object -First 1 +if (-not $bootstrapEntry) { throw "Creator bootstrap token is not configured" } +$bootstrapToken = $bootstrapEntry.Substring("CREATOR_BOOTSTRAP_TOKEN=".Length) +$sessionResponse = docker --context $DockerContext exec --env "CREATOR_TEST_BOOTSTRAP=$bootstrapToken" $container sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_BOOTSTRAP" --post-data="" "$0/v1/creator/sessions"' $baseURL +if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" } +$creatorSession = ($sessionResponse | ConvertFrom-Json).Token +if (-not $creatorSession) { throw "Creator session token is empty" } + +$suffix = [guid]::NewGuid().ToString("N") +$sessionID = "creator-session-$suffix" +$workspaceID = "creator-workspace-$suffix" +$goalID = "creator-goal-$suffix" +$scopeID = "domovoy-global-scope" +$taskID = "creator-task-$suffix" +$outsideTaskID = "outside-task-$suffix" +$capabilityID = "creator-capability-$suffix" + +Post-Json "$baseURL/v1/focus-bindings" @{ + ID = "creator-focus-$suffix"; ActorID = "domovoy-creator-actor"; SessionID = $sessionID + Workspace = $workspaceID; ProjectID = "creator-project-$suffix"; GoalID = $goalID + ScopeID = $scopeID; Active = $true +} | Out-Null +Post-Json "$baseURL/v1/tasks" @{TaskID = $taskID; GoalID = $goalID; ScopeID = $scopeID; Revision = 1} | Out-Null +Post-Json "$baseURL/v1/tasks" @{TaskID = $outsideTaskID; GoalID = "outside-goal-$suffix"; ScopeID = $scopeID; Revision = 1} | Out-Null +Post-Json "$baseURL/v1/capabilities/health" @{ + CapabilityID = $capabilityID; State = "AVAILABLE"; Reason = "NONE"; Revision = 1 + SemanticVersion = 2; ObservedAtUnixMillis = 1800000000000 +} | Out-Null + +$query = "session_id=$sessionID&workspace_id=$workspaceID" +Expect-GetStatus "$baseURL/v1/creator/focus?$query" 401 + +$focus = Get-Authenticated "$baseURL/v1/creator/focus?$query" $creatorSession | ConvertFrom-Json +if ($focus.ActorID -ne "domovoy-creator-actor" -or $focus.GoalID -ne $goalID) { throw "Creator focus response is incorrect" } + +$task = Get-Authenticated "$baseURL/v1/creator/tasks/${taskID}?$query" $creatorSession | ConvertFrom-Json +if ($task.TaskID -ne $taskID -or $task.GoalID -ne $goalID) { throw "Creator task response is incorrect" } +Expect-GetStatus "$baseURL/v1/creator/tasks/${outsideTaskID}?$query" 403 $creatorSession + +$health = Get-Authenticated "$baseURL/v1/creator/capabilities/$capabilityID/health" $creatorSession | ConvertFrom-Json +if ($health.CapabilityID -ne $capabilityID -or $health.State -ne "AVAILABLE") { throw "Creator capability health response is incorrect" } + +Write-Output "DEV-000024 authenticated Creator read API: PASS"