diff --git a/docs/codebase/prompt-inbox-provenance.md b/docs/codebase/prompt-inbox-provenance.md index f56fa56c0..610b6b6ba 100644 --- a/docs/codebase/prompt-inbox-provenance.md +++ b/docs/codebase/prompt-inbox-provenance.md @@ -1,6 +1,6 @@ # RFC: authenticated prompt inbox provenance before remote deletion -**Status: proposed end-to-end contract; cloud registration and pair-claim routes exist, local origin marking and eligible-local-origin autosync preflight are implemented, but no verified cloud delete gate or old/imported source reauthorization exists.** This RFC defines the minimum non-cryptographic authority needed before #1464 can enter the merge queue. The RFC alone does not establish end-to-end enforcement. The baseline is tracker `313f5269`; the review thread on #1464 records the hold. #1240 is separate. +**Status: proposed end-to-end contract; cloud registration, pair-claim and explicit source-attestation routes exist, local origin marking and eligible-local-origin autosync preflight are implemented, but no verified cloud delete gate exists.** This RFC defines the minimum non-cryptographic authority needed before #1464 can enter the merge queue. The RFC alone does not establish end-to-end enforcement. The baseline is tracker `313f5269`; the review thread on #1464 records the hold. #1240 is separate. ## Decision in one minute @@ -16,7 +16,7 @@ This is a server-enforced authorization contract, not a new inference rule based | Claim prompt pair | For a beta prompt referencing an alpha session, the claimant must hold authorization for **both** alpha and beta at claim time. Registration must already be verified. An authorized claim durably binds `(session_id, source_inbox_id)` to `(sync_id, beta)`; matching replay is idempotent and any competing sync ID or project is a conflict. Claims cannot bootstrap registration or be inferred from a beta upsert. Same-project claims still require session-owner and prompt-project authority; no special weaker bootstrap. | | Apply verified delete | A delete may reserve or replay a remote pair only against the existing verified binding, with beta authorization for the mutation. It need not require renewed alpha authorization: alpha consent was checked at the original claim. Validate sync ID, session ID, inbox ID and beta project against that binding. A beta-only writer cannot create or change the claim, even by sending an upsert followed by a delete; an alpha-only writer cannot claim/delete beta. | -Registration and claim are explicit authenticated server operations (`POST /sync/session-authorities` and `POST /sync/prompt-pair-claims`); autosync now performs the handshake only for independently eligible local keyed prompt mutations. The verified-delete gate and explicit old/imported source reauthorization remain pending. Atomic uniqueness and conflict checks must survive concurrent retries. Authentication means server-verified principal and project grants, not fields supplied in the payload; no cryptographic offline capability is assumed. Revocation after a verified claim does not erase that historical binding, but current beta mutation authorization remains necessary. Idless legacy prompts remain pair-less and must not acquire a source inbox binding through inference. +Registration and claim are explicit authenticated server operations (`POST /sync/session-authorities` and `POST /sync/prompt-pair-claims`); autosync now performs the handshake only for independently eligible local keyed prompt mutations. For older sources, `POST /sync/prompt-source-attestations` accepts `session_id`, `source_inbox_id`, `sync_id`, `owner_project`, and `prompt_project` from an authenticated human principal with current grants to both projects. It registers the owner and claims the exact pair before appending an attestation attributed to that principal. The route records a present-day assertion, **not** proof of historical creation; a failed final append does not attest the source even if registration or claim succeeded. Attestation does not admit remote deletes or authorize local origin markers. The verified-delete gate and client-side use of explicit old/imported source reauthorization remain pending. Atomic uniqueness and conflict checks must survive concurrent retries. Authentication means server-verified principal and project grants, not fields supplied in the payload; no cryptographic offline capability is assumed. Revocation after a verified claim does not erase that historical binding, but current beta mutation authorization remains necessary. Idless legacy prompts remain pair-less and must not acquire a source inbox binding through inference. ## Unverified deletes and compatibility diff --git a/internal/cloud/cloudserver/cloudserver.go b/internal/cloud/cloudserver/cloudserver.go index 6aa4bea70..4d979eed7 100644 --- a/internal/cloud/cloudserver/cloudserver.go +++ b/internal/cloud/cloudserver/cloudserver.go @@ -317,6 +317,7 @@ func (s *CloudServer) routes() { s.mux.HandleFunc("POST /sync/push", s.withAuth(s.handlePushChunk)) s.mux.HandleFunc("POST /sync/session-authorities", s.withAuth(s.handleRegisterSessionAuthority)) s.mux.HandleFunc("POST /sync/prompt-pair-claims", s.withAuth(s.handlePromptPairClaim)) + s.mux.HandleFunc("POST /sync/prompt-source-attestations", s.withAuth(s.handlePromptSourceAttestation)) s.mux.HandleFunc("POST /sync/mutations/push", s.withAuth(s.handleMutationPush)) s.mux.HandleFunc("GET /sync/mutations/pull", s.withAuth(s.handleMutationPull)) s.mux.HandleFunc("GET /admin/users", s.withAuth(s.handleAdminListUsers)) diff --git a/internal/cloud/cloudserver/prompt_source_attestation.go b/internal/cloud/cloudserver/prompt_source_attestation.go new file mode 100644 index 000000000..13d0dd64c --- /dev/null +++ b/internal/cloud/cloudserver/prompt_source_attestation.go @@ -0,0 +1,117 @@ +package cloudserver + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + cloudauth "github.com/Gentleman-Programming/engram/v2/internal/cloud/auth" + "github.com/Gentleman-Programming/engram/v2/internal/cloud/cloudstore" + "github.com/Gentleman-Programming/engram/v2/internal/store" +) + +type promptSourceAttestationStore interface { + RegisterSessionAuthority(context.Context, string, string, string) error + ClaimPromptPair(context.Context, string, string, string, string, string) error + AttestPromptSource(context.Context, string, string, string, string, string, string) (*cloudstore.PromptSourceAttestation, error) +} + +func (s *CloudServer) handlePromptSourceAttestation(w http.ResponseWriter, r *http.Request) { + principal, ok := PrincipalFromContext(r.Context()) + if s.auth == nil || !ok || strings.TrimSpace(principal.ID) == "" { + http.Error(w, "human authentication required", http.StatusUnauthorized) + return + } + if principal.Kind != cloudauth.PrincipalKindHuman { + http.Error(w, "human principal required", http.StatusForbidden) + return + } + if s.principalProject == nil { + http.Error(w, "project authorization unavailable", http.StatusForbidden) + return + } + var p struct { + SessionID string `json:"session_id"` + SourceInboxID string `json:"source_inbox_id"` + SyncID string `json:"sync_id"` + OwnerProject string `json:"owner_project"` + PromptProject string `json:"prompt_project"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, s.pushBodyLimit())) + decoder.DisallowUnknownFields() + invalid := func(err error) { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + http.Error(w, "attestation request too large", http.StatusRequestEntityTooLarge) + } else { + http.Error(w, "invalid attestation request", http.StatusBadRequest) + } + } + if err := decoder.Decode(&p); err != nil { + invalid(err) + return + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + invalid(err) + return + } + session, inbox, syncID := strings.TrimSpace(p.SessionID), strings.TrimSpace(p.SourceInboxID), strings.TrimSpace(p.SyncID) + owner, prompt := strings.TrimSpace(p.OwnerProject), strings.TrimSpace(p.PromptProject) + if session == "" || inbox == "" || syncID == "" || owner == "" || prompt == "" { + http.Error(w, "all attestation fields are required", http.StatusBadRequest) + return + } + owner, _ = store.NormalizeProject(owner) + prompt, _ = store.NormalizeProject(prompt) + if strings.TrimSpace(owner) == "" || strings.TrimSpace(prompt) == "" { + http.Error(w, "projects are required", http.StatusBadRequest) + return + } + // Both current grants precede any global session identity access. + if err := s.principalProject.AuthorizeProjectForPrincipal(r.Context(), principal, owner); err != nil { + http.Error(w, "owner project forbidden", http.StatusForbidden) + return + } + if err := s.principalProject.AuthorizeProjectForPrincipal(r.Context(), principal, prompt); err != nil { + http.Error(w, "prompt project forbidden", http.StatusForbidden) + return + } + attestations, ok := s.store.(promptSourceAttestationStore) + if !ok { + http.Error(w, "attestation store unavailable", http.StatusInternalServerError) + return + } + actor := strings.TrimSpace(principal.ID) + if err := attestations.RegisterSessionAuthority(r.Context(), session, owner, actor); err != nil { + if errors.Is(err, cloudstore.ErrSessionAuthorityConflict) { + http.Error(w, "session authority conflict", http.StatusConflict) + } else { + http.Error(w, "registration storage unavailable", http.StatusInternalServerError) + } + return + } + if err := attestations.ClaimPromptPair(r.Context(), session, inbox, syncID, prompt, actor); err != nil { + if errors.Is(err, cloudstore.ErrPromptPairClaimConflict) { + http.Error(w, "prompt pair conflict", http.StatusConflict) + } else if errors.Is(err, cloudstore.ErrSessionAuthorityNotFound) { + sessionAuthorityUnavailable(w) + } else { + http.Error(w, "claim storage unavailable", http.StatusInternalServerError) + } + return + } + record, err := attestations.AttestPromptSource(r.Context(), session, inbox, syncID, owner, prompt, actor) + if err != nil { + if errors.Is(err, cloudstore.ErrPromptSourceAttestationUnbound) { + http.Error(w, "attestation binding conflict", http.StatusConflict) + } else { + http.Error(w, "attestation storage unavailable", http.StatusInternalServerError) + } + return + } + jsonResponse(w, http.StatusOK, map[string]any{"status": "ok", "attestation_id": record.ID}) +} diff --git a/internal/cloud/cloudserver/prompt_source_attestation_test.go b/internal/cloud/cloudserver/prompt_source_attestation_test.go new file mode 100644 index 000000000..1fbe722fb --- /dev/null +++ b/internal/cloud/cloudserver/prompt_source_attestation_test.go @@ -0,0 +1,137 @@ +package cloudserver + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + cloudauth "github.com/Gentleman-Programming/engram/v2/internal/cloud/auth" + "github.com/Gentleman-Programming/engram/v2/internal/cloud/cloudstore" +) + +type attestationTestStore struct { + fakeStore + registration, claim, attest int + actor string + registrationErr, claimErr, attestErr error +} + +func (s *attestationTestStore) RegisterSessionAuthority(_ context.Context, _, _, actor string) error { + s.registration++ + s.actor = actor + return s.registrationErr +} +func (s *attestationTestStore) ClaimPromptPair(_ context.Context, _, _, _, _, actor string) error { + s.claim++ + s.actor = actor + return s.claimErr +} +func (s *attestationTestStore) AttestPromptSource(_ context.Context, _, _, _, _, _, actor string) (*cloudstore.PromptSourceAttestation, error) { + s.attest++ + s.actor = actor + if s.attestErr != nil { + return nil, s.attestErr + } + return &cloudstore.PromptSourceAttestation{ID: 42, ActorID: actor}, nil +} + +func TestPromptSourceAttestationBearerBoundary(t *testing.T) { + body := `{"session_id":"session","source_inbox_id":"inbox","sync_id":"sync","owner_project":"alpha","prompt_project":"beta"}` + human := cloudauth.Principal{ID: "human", Kind: cloudauth.PrincipalKindHuman, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + legacy := cloudauth.Principal{ID: "legacy:sync", Kind: cloudauth.PrincipalKindLegacy, Source: cloudauth.PrincipalSourceLegacyEnvSync, Enabled: true} + service := cloudauth.Principal{ID: "service", Kind: cloudauth.PrincipalKindServiceAccount, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + auth := resolvingAuth{ + principals: map[string]cloudauth.Principal{"human-token": human, "legacy-token": legacy, "service-token": service}, + errors: map[string]error{"revoked-token": cloudauth.ErrTokenRevoked, "disabled-token": cloudauth.ErrPrincipalDisabled}, + } + for _, tc := range []struct { + name, token string + want, calls int + }{ + {name: "human with dual grants", token: "human-token", want: 200, calls: 1}, + {name: "missing header", want: 401}, + {name: "revoked token", token: "revoked-token", want: 401}, + {name: "disabled principal", token: "disabled-token", want: 401}, + {name: "legacy token", token: "legacy-token", want: 403}, + {name: "service token", token: "service-token", want: 403}, + } { + t.Run(tc.name, func(t *testing.T) { + st := &attestationTestStore{} + srv := New(st, auth, 0, WithPrincipalProjectAuthorizer(managedGrantAuthorizer{grants: map[string][]string{"human": {"alpha", "beta"}}})) + req := httptest.NewRequest(http.MethodPost, "/sync/prompt-source-attestations", strings.NewReader(body)) + if tc.token != "" { + req.Header.Set("Authorization", "Bearer "+tc.token) + } + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != tc.want || st.registration != tc.calls || st.claim != tc.calls || st.attest != tc.calls { + t.Fatalf("status=%d body=%q calls=%d/%d/%d; want %d and %d each", w.Code, w.Body.String(), st.registration, st.claim, st.attest, tc.want, tc.calls) + } + if tc.calls == 1 { + if st.actor != human.ID { + t.Fatalf("actor=%q, want %q", st.actor, human.ID) + } + var response struct { + Status string `json:"status"` + AttestationID int64 `json:"attestation_id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.Status != "ok" || response.AttestationID != 42 { + t.Fatalf("response=%q, decoded=%+v, err=%v", w.Body.String(), response, err) + } + } + }) + } +} + +func TestPromptSourceAttestationAdmission(t *testing.T) { + body := `{"session_id":"session","source_inbox_id":"inbox","sync_id":"sync","owner_project":"alpha","prompt_project":"beta"}` + human := cloudauth.Principal{ID: "human", Kind: cloudauth.PrincipalKindHuman, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true} + cases := []struct { + name, body string + principal cloudauth.Principal + grants []string + regErr, claimErr, attErr error + limit int64 + want, reg, claim, att int + }{ + {name: "dual grants", body: body, principal: human, grants: []string{"alpha", "beta"}, want: 200, reg: 1, claim: 1, att: 1}, + {name: "owner grant absent", body: body, principal: human, grants: []string{"beta"}, want: 403}, + {name: "prompt grant absent", body: body, principal: human, grants: []string{"alpha"}, want: 403}, + {name: "no principal", body: body, want: 401}, + {name: "blank principal", body: body, principal: cloudauth.Principal{Kind: cloudauth.PrincipalKindHuman}, want: 401}, + {name: "service", body: body, principal: cloudauth.Principal{ID: "service", Kind: cloudauth.PrincipalKindServiceAccount, Source: cloudauth.PrincipalSourceManagedToken, Enabled: true}, want: 403}, + {name: "owner conflict", body: body, principal: human, grants: []string{"alpha", "beta"}, regErr: cloudstore.ErrSessionAuthorityConflict, want: 409, reg: 1}, + {name: "pair conflict", body: body, principal: human, grants: []string{"alpha", "beta"}, claimErr: cloudstore.ErrPromptPairClaimConflict, want: 409, reg: 1, claim: 1}, + {name: "attestation unbound", body: body, principal: human, grants: []string{"alpha", "beta"}, attErr: cloudstore.ErrPromptSourceAttestationUnbound, want: 409, reg: 1, claim: 1, att: 1}, + {name: "persistence failure", body: body, principal: human, grants: []string{"alpha", "beta"}, attErr: errors.New("database unavailable"), want: 500, reg: 1, claim: 1, att: 1}, + {name: "unknown actor", body: strings.TrimSuffix(body, "}") + `,"actor_id":"spoof"}`, principal: human, grants: []string{"alpha", "beta"}, want: 400}, + {name: "malformed", body: "{", principal: human, grants: []string{"alpha", "beta"}, want: 400}, + {name: "blank", body: strings.Replace(body, `"sync_id":"sync"`, `"sync_id":" "`, 1), principal: human, grants: []string{"alpha", "beta"}, want: 400}, + {name: "trailing", body: body + ` {}`, principal: human, grants: []string{"alpha", "beta"}, want: 400}, + {name: "oversize", body: body, principal: human, grants: []string{"alpha", "beta"}, limit: 20, want: 413}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := &attestationTestStore{registrationErr: tc.regErr, claimErr: tc.claimErr, attestErr: tc.attErr} + opts := []Option{WithPrincipalProjectAuthorizer(managedGrantAuthorizer{grants: map[string][]string{tc.principal.ID: tc.grants}})} + if tc.limit > 0 { + opts = append(opts, WithMaxPushBodyBytes(tc.limit)) + } + srv := New(st, claimAuthOnly{}, 0, opts...) + req := httptest.NewRequest(http.MethodPost, "/sync/prompt-source-attestations", strings.NewReader(tc.body)) + req = req.WithContext(WithPrincipal(req.Context(), tc.principal)) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + if w.Code != tc.want || st.registration != tc.reg || st.claim != tc.claim || st.attest != tc.att { + t.Fatalf("status=%d body=%q calls=%d/%d/%d, want %d and %d/%d/%d", w.Code, w.Body.String(), st.registration, st.claim, st.attest, tc.want, tc.reg, tc.claim, tc.att) + } + if st.attest > 0 && st.actor != "human" { + t.Fatalf("actor=%q", st.actor) + } + }) + } +}