feat: authorize Creator continuity reads
CI / test (push) Failing after 15m12s

This commit is contained in:
m
2026-08-16 15:45:39 +03:00
parent 0d76c81f00
commit eb343cd76e
7 changed files with 237 additions and 3 deletions
@@ -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
}
@@ -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)
}
}
+39 -3
View File
@@ -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
}
+1
View File
@@ -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:
+1
View File
@@ -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 } }
@@ -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"]
}
@@ -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"