diff --git a/cmd/continuity-runtime/creator_identity.go b/cmd/continuity-runtime/creator_identity.go new file mode 100644 index 0000000..af2d347 --- /dev/null +++ b/cmd/continuity-runtime/creator_identity.go @@ -0,0 +1,65 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "git.cin.su/m/Assistant/contracts/identity" +) + +var ( + errCreatorSessionDenied = errors.New("creator session denied") + errIdentityUnavailable = errors.New("identity service unavailable") +) + +type creatorIdentityClient struct { + baseURL *url.URL + client *http.Client +} + +func newCreatorIdentityClient(rawURL string, client *http.Client) (creatorIdentityClient, error) { + baseURL, err := url.Parse(rawURL) + if err != nil || baseURL.Scheme == "" || baseURL.Host == "" { + return creatorIdentityClient{}, fmt.Errorf("invalid Core Runtime URL") + } + return creatorIdentityClient{baseURL: baseURL, client: client}, nil +} + +func (c creatorIdentityClient) Session(ctx context.Context, authorization string) (identity.CreatorSession, error) { + if !strings.HasPrefix(authorization, "Bearer ") || len(strings.TrimPrefix(authorization, "Bearer ")) == 0 { + return identity.CreatorSession{}, errCreatorSessionDenied + } + target := *c.baseURL + target.Path = "/v1/creator/sessions/current" + target.RawQuery = "" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return identity.CreatorSession{}, fmt.Errorf("%w: build request", errIdentityUnavailable) + } + request.Header.Set("Authorization", authorization) + response, err := c.client.Do(request) + if err != nil { + return identity.CreatorSession{}, fmt.Errorf("%w: %v", errIdentityUnavailable, err) + } + defer response.Body.Close() + if response.StatusCode == http.StatusUnauthorized { + return identity.CreatorSession{}, errCreatorSessionDenied + } + if response.StatusCode != http.StatusOK { + return identity.CreatorSession{}, fmt.Errorf("%w: status %d", errIdentityUnavailable, response.StatusCode) + } + var session identity.CreatorSession + if err := json.NewDecoder(io.LimitReader(response.Body, 16<<10)).Decode(&session); err != nil { + return identity.CreatorSession{}, fmt.Errorf("%w: invalid response", errIdentityUnavailable) + } + if session.ActorID == "" || session.PrincipalID == "" { + return identity.CreatorSession{}, fmt.Errorf("%w: incomplete session", errIdentityUnavailable) + } + return session, nil +} diff --git a/cmd/continuity-runtime/creator_identity_test.go b/cmd/continuity-runtime/creator_identity_test.go new file mode 100644 index 0000000..15e0add --- /dev/null +++ b/cmd/continuity-runtime/creator_identity_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "git.cin.su/m/Assistant/contracts/identity" +) + +func TestCreatorIdentityClientValidatesThroughCore(t *testing.T) { + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/creator/sessions/current" || r.Header.Get("Authorization") != "Bearer bounded" { + t.Fatal("unexpected Identity request") + } + _ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor", PrincipalID: "principal", ExpiresAtUnix: 100}) + })) + defer core.Close() + client, err := newCreatorIdentityClient(core.URL, core.Client()) + if err != nil { + t.Fatal(err) + } + session, err := client.Session(context.Background(), "Bearer bounded") + if err != nil || session.PrincipalID != "principal" { + t.Fatalf("session = %+v, err = %v", session, err) + } +} + +func TestCreatorIdentityClientDistinguishesDenialFromOutage(t *testing.T) { + denyingCore := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "denied", http.StatusUnauthorized) + })) + defer denyingCore.Close() + client, err := newCreatorIdentityClient(denyingCore.URL, denyingCore.Client()) + if err != nil { + t.Fatal(err) + } + if _, err := client.Session(context.Background(), "Bearer invalid"); !errors.Is(err, errCreatorSessionDenied) { + t.Fatalf("denial error = %v", err) + } + + unavailableCore := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer unavailableCore.Close() + client, err = newCreatorIdentityClient(unavailableCore.URL, unavailableCore.Client()) + if err != nil { + t.Fatal(err) + } + if _, err := client.Session(context.Background(), "Bearer bounded"); !errors.Is(err, errIdentityUnavailable) { + t.Fatalf("availability error = %v", err) + } +} diff --git a/cmd/continuity-runtime/main.go b/cmd/continuity-runtime/main.go index d8dd216..d5657db 100644 --- a/cmd/continuity-runtime/main.go +++ b/cmd/continuity-runtime/main.go @@ -3,12 +3,15 @@ package main import ( "context" "encoding/json" - "git.cin.su/m/Assistant/adapters/postgres" - "git.cin.su/m/Assistant/contracts/continuity" - "git.cin.su/m/Assistant/runtime/health" + "errors" "log" "net/http" "os" + "time" + + "git.cin.su/m/Assistant/adapters/postgres" + "git.cin.su/m/Assistant/contracts/continuity" + "git.cin.su/m/Assistant/runtime/health" ) func main() { @@ -21,6 +24,10 @@ func main() { log.Fatal(err) } defer repository.Close() + identityClient, err := newCreatorIdentityClient(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 5 * time.Second}) + if err != nil { + log.Fatal(err) + } mux := http.NewServeMux() mux.Handle("/healthz", health.Handler{Runtime: "continuity-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1}) mux.HandleFunc("POST /v1/prospective-items", func(w http.ResponseWriter, r *http.Request) { @@ -44,8 +51,37 @@ func main() { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(item) }) + mux.HandleFunc("GET /v1/creator/prospective-items/{id}", func(w http.ResponseWriter, r *http.Request) { + session, err := identityClient.Session(r.Context(), r.Header.Get("Authorization")) + if errors.Is(err, errCreatorSessionDenied) { + http.Error(w, "creator session denied", http.StatusUnauthorized) + return + } + if err != nil { + http.Error(w, "identity service unavailable", http.StatusServiceUnavailable) + return + } + item, err := repository.Load(r.Context(), continuity.ProspectiveItemID(r.PathValue("id"))) + if err != nil { + http.Error(w, "prospective item not found", http.StatusNotFound) + return + } + if item.OwnerPrincipalID != session.PrincipalID { + http.Error(w, "prospective item denied", http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(item) + }) log.Printf("continuity-runtime listening on %s", addr) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatal(err) } } + +func envOr(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 01323e0..1613bd0 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -64,6 +64,7 @@ services: POSTGRES_DATABASE: assistant POSTGRES_USER: continuity_role POSTGRES_PASSWORD: ${CONTINUITY_DB_PASSWORD:-assistant_continuity_dev_only} + CORE_RUNTIME_URL: http://core-runtime:8080 ports: ["19081:8081"] commit-runtime: diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index bd2ffb7..a16aea5 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -60,6 +60,7 @@ services: POSTGRES_DATABASE: ${POSTGRES_DB:-assistant} POSTGRES_USER: continuity_role POSTGRES_PASSWORD: ${CONTINUITY_DB_PASSWORD} + CORE_RUNTIME_URL: http://core-runtime:8080 commit-runtime: build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: commit-runtime } } diff --git a/dev/tasks/DEV-000026-authenticated-continuity-read-api.json b/dev/tasks/DEV-000026-authenticated-continuity-read-api.json new file mode 100644 index 0000000..5b8a510 --- /dev/null +++ b/dev/tasks/DEV-000026-authenticated-continuity-read-api.json @@ -0,0 +1,17 @@ +{ + "task_id": "DEV-000026", + "task_revision": 1, + "architecture_revision": "bootstrap-1", + "base_repo_revision": "0d76c81f009730dbec2728621eb8f07e9cfa4c69", + "risk_tier": "C", + "intent": "Expose a read-only Creator API for Continuity ProspectiveItems by validating the Creator session through the Identity-owned Core API and enforcing owner-principal isolation in Continuity.", + "allowed_change_scope": {"paths": ["cmd/continuity-runtime/**", "deploy/docker-compose.prod.yml", "deploy/docker-compose.dev.yml", "tests/creator_continuity/**", "dev/tasks/DEV-000026-authenticated-continuity-read-api.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "modules/**", "contracts/**", "adapters/**", "cmd/core-runtime/**", "cmd/commit-runtime/**", "cmd/creator-web/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0}, + "contracts": {"consume": ["contracts.identity@v1", "contracts.continuity@v1"], "may_change": []}, + "semantic_owner": "continuity", + "assumptions": ["Core Runtime is the deployed Identity API endpoint for Creator session validation", "Creator reads are restricted to ProspectiveItems owned by the authenticated Principal"], + "acceptance_scenarios": ["missing or invalid Creator session is denied", "Identity service unavailability does not become authentication denial", "Creator can read an owned ProspectiveItem", "Creator cannot read another Principal's ProspectiveItem", "the read endpoint does not mutate canonical Continuity state"], + "required_tests": ["unit", "runtime HTTP", "owner-principal denial", "architecture", "remote Docker smoke"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY"] +} diff --git a/tests/creator_continuity/verify-remote.ps1 b/tests/creator_continuity/verify-remote.ps1 new file mode 100644 index 0000000..4c5887e --- /dev/null +++ b/tests/creator_continuity/verify-remote.ps1 @@ -0,0 +1,58 @@ +param([string]$DockerContext = "docker-prod") + +$ErrorActionPreference = "Stop" +$continuityContainer = "assistant-continuity-runtime-1" +$coreContainer = "assistant-core-runtime-1" +$continuityURL = "http://localhost:8081" +$coreURL = "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 "${continuityContainer}:$remote" | Out-Null + docker --context $DockerContext exec $continuityContainer wget -qO- --header "Content-Type: application/json" --post-file $remote $url + if ($LASTEXITCODE -ne 0) { throw "POST $url failed" } + } finally { + docker --context $DockerContext exec --user root $continuityContainer rm -f $remote 2>$null + Remove-Item -LiteralPath $local -Force + } +} + +function Expect-GetStatus([string]$url, [int]$status, [string]$token = "") { + $previous = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $response = (docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$token" $continuityContainer 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" } +} + +$environment = docker --context $DockerContext inspect --format '{{range .Config.Env}}{{println .}}{{end}}' $coreContainer +$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" $coreContainer sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_BOOTSTRAP" --post-data="" "$0/v1/creator/sessions"' $coreURL +if ($LASTEXITCODE -ne 0) { throw "Creator session issuance failed" } +$creatorSession = ($sessionResponse | ConvertFrom-Json).Token + +$suffix = [guid]::NewGuid().ToString("N") +$ownedID = "creator-prospective-$suffix" +$foreignID = "foreign-prospective-$suffix" +$common = @{ + CommitmentID = "commitment-$suffix"; ScopeID = "domovoy-global-scope"; GoalID = "goal-$suffix" + TriggerAtUnixMillis = 1800000000000; State = "ARMED"; Revision = 1 +} +Post-Json "$continuityURL/v1/prospective-items" ($common + @{ID=$ownedID;OwnerPrincipalID="domovoy-creator-principal"}) +Post-Json "$continuityURL/v1/prospective-items" ($common + @{ID=$foreignID;OwnerPrincipalID="another-principal"}) + +Expect-GetStatus "$continuityURL/v1/creator/prospective-items/$ownedID" 401 +$owned = docker --context $DockerContext exec --env "CREATOR_TEST_SESSION=$creatorSession" $continuityContainer sh -c 'wget -qO- --header="Authorization: Bearer $CREATOR_TEST_SESSION" "$0"' "$continuityURL/v1/creator/prospective-items/$ownedID" +if ($LASTEXITCODE -ne 0 -or ($owned | ConvertFrom-Json).ID -ne $ownedID) { throw "owned ProspectiveItem was not returned" } +Expect-GetStatus "$continuityURL/v1/creator/prospective-items/$foreignID" 403 $creatorSession + +Write-Output "DEV-000026 authenticated Creator Continuity API: PASS"