Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/codebase/prompt-inbox-provenance.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions internal/cloud/cloudserver/cloudserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
117 changes: 117 additions & 0 deletions internal/cloud/cloudserver/prompt_source_attestation.go
Original file line number Diff line number Diff line change
@@ -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})
}
137 changes: 137 additions & 0 deletions internal/cloud/cloudserver/prompt_source_attestation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading