This commit is contained in:
+70
-10
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user