diff --git a/coreauth/coreauth.go b/coreauth/coreauth.go new file mode 100644 index 0000000000000000000000000000000000000000..79e5a8543706e443685f35357eef26f960c8e119 --- /dev/null +++ b/coreauth/coreauth.go @@ -0,0 +1,38 @@ +// Package coreauth bridges spec.sr.ht's own principal model (authn.Principal) +// to core-go's auth.AuthContext, which the core-go webhook engine requires in +// context. spec keeps authn as its real authorization; this is a compatibility +// shim, nothing more. +// +// The owner (and an agent, which acts for the owner) map to AUTH_INTERNAL, not +// AUTH_COOKIE, for two reasons: INTERNAL bypasses core-go's @access scope +// checks (AuthContext.Access short-circuits for INTERNAL), and core-go's +// webhooks.NewAuthConfig REFUSES cookie auth outright but accepts INTERNAL — so +// mapping the owner to INTERNAL is what lets the single owner create webhooks +// at all. The agent identity that triggers an event is not represented here; it +// rides in the webhook payload (the proposal), and webhook management is +// owner-gated in the resolvers regardless. +package coreauth + +import ( + "context" + + "sourcecraft.dev/bigbes/sr-ht-core/auth" + + "sourcecraft.dev/bigbes/sr-ht-spec/authn" +) + +// Derive maps a spec principal to a core-go AuthContext. See the package doc +// for why owner and agent both become AUTH_INTERNAL. +func Derive(p authn.Principal, ownerUserID int) *auth.AuthContext { + switch { + case p.IsOwner() || p.IsAgent(): + return &auth.AuthContext{AuthMethod: auth.AUTH_INTERNAL, UserID: ownerUserID, Username: p.Owner} + default: + return &auth.AuthContext{AuthMethod: auth.AUTH_ANON_INTERNAL} + } +} + +// Context installs a derived AuthContext for principal p onto ctx. +func Context(ctx context.Context, p authn.Principal, ownerUserID int) context.Context { + return auth.Context(ctx, Derive(p, ownerUserID)) +} diff --git a/coreauth/coreauth_test.go b/coreauth/coreauth_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0d569f2b2a8f32acaa061260c93652fcf580886e --- /dev/null +++ b/coreauth/coreauth_test.go @@ -0,0 +1,78 @@ +package coreauth + +import ( + "context" + "testing" + + "sourcecraft.dev/bigbes/sr-ht-core/auth" + + "sourcecraft.dev/bigbes/sr-ht-spec/authn" +) + +func TestDerive(t *testing.T) { + const ownerUserID = 42 + + tests := []struct { + name string + principal authn.Principal + wantMethod string + wantUserID int + wantUser string + }{ + { + name: "owner", + principal: authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"}, + wantMethod: auth.AUTH_INTERNAL, + wantUserID: ownerUserID, + wantUser: "bigbes", + }, + { + name: "agent", + principal: authn.Principal{Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude-code/spec-writer"}, + wantMethod: auth.AUTH_INTERNAL, + wantUserID: ownerUserID, + wantUser: "bigbes", + }, + { + name: "anonymous", + principal: authn.Anonymous(), + wantMethod: auth.AUTH_ANON_INTERNAL, + wantUserID: 0, + wantUser: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac := Derive(tt.principal, ownerUserID) + if ac.AuthMethod != tt.wantMethod { + t.Errorf("AuthMethod = %q, want %q", ac.AuthMethod, tt.wantMethod) + } + if ac.UserID != tt.wantUserID { + t.Errorf("UserID = %d, want %d", ac.UserID, tt.wantUserID) + } + if ac.Username != tt.wantUser { + t.Errorf("Username = %q, want %q", ac.Username, tt.wantUser) + } + }) + } +} + +func TestContext(t *testing.T) { + const ownerUserID = 7 + ctx := Context(context.Background(), authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"}, ownerUserID) + + ac := auth.ForContext(ctx) + if ac == nil { + t.Fatal("auth.ForContext returned nil; expected an AuthContext") + } + if ac.AuthMethod != auth.AUTH_INTERNAL { + t.Errorf("AuthMethod = %q, want %q", ac.AuthMethod, auth.AUTH_INTERNAL) + } + if ac.UserID != ownerUserID { + t.Errorf("UserID = %d, want %d", ac.UserID, ownerUserID) + } + if ac.Username != "bigbes" { + t.Errorf("Username = %q, want %q", ac.Username, "bigbes") + } +} diff --git a/db/user.go b/db/user.go new file mode 100644 index 0000000000000000000000000000000000000000..c16567f0aca9a793768bf9b70acae97b461c5220 --- /dev/null +++ b/db/user.go @@ -0,0 +1,22 @@ +package db + +import ( + "context" + "fmt" +) + +// EnsureUser upserts a user row by username and returns its id. spec.sr.ht is +// single-owner; this seeds the one row core-go's user-scoped webhook model +// references (user_id FK). Idempotent — the daemon calls it every startup. +func (s *Store) EnsureUser(ctx context.Context, username string) (int, error) { + const q = ` +INSERT INTO "user" (created, updated, username, email, user_type) +VALUES (NOW() at time zone 'utc', NOW() at time zone 'utc', $1, '', 'USER') +ON CONFLICT (username) DO UPDATE SET updated = NOW() at time zone 'utc' +RETURNING id` + var id int + if err := s.q.QueryRowContext(ctx, q, username).Scan(&id); err != nil { + return 0, fmt.Errorf("ensure user %q: %w", username, err) + } + return id, nil +} diff --git a/db/user_test.go b/db/user_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6cdbd9bc274f4c78af38cfca23394e14b8fce479 --- /dev/null +++ b/db/user_test.go @@ -0,0 +1,38 @@ +package db + +import ( + "context" + "testing" +) + +func TestEnsureUser(t *testing.T) { + s, _, cleanup := newTestStore(t) + defer cleanup() + ctx := context.Background() + + id, err := s.EnsureUser(ctx, "bigbes") + if err != nil { + t.Fatalf("ensure user (insert): %v", err) + } + if id == 0 { + t.Fatal("expected a non-zero user id") + } + + // Idempotent: a second call for the same username returns the same id. + id2, err := s.EnsureUser(ctx, "bigbes") + if err != nil { + t.Fatalf("ensure user (upsert): %v", err) + } + if id2 != id { + t.Fatalf("ensure user id changed: got %d, want %d", id2, id) + } + + // A different username gets its own row. + other, err := s.EnsureUser(ctx, "someone") + if err != nil { + t.Fatalf("ensure other user: %v", err) + } + if other == id { + t.Fatalf("distinct username reused id %d", id) + } +} diff --git a/service/service.go b/service/service.go index a15e70fde585311cb4d55c95593a3e3084c87148..38a75608dd9b60840007d476abf2096abd94430b 100644 --- a/service/service.go +++ b/service/service.go @@ -218,6 +218,12 @@ store *db.Store tokens *TokenStore resolver *authn.Resolver + // ownerUserID caches the id of the owner's "user" row, seeded by + // EnsureOwnerUser at startup. Zero until then. It is the user_id the + // core-go webhook engine's user-scoped subscriptions FK against and the + // UserID the coreauth bridge stamps on the owner's AuthContext. + ownerUserID int + // grace is how long a proposal row with no branch is left alone before the // reconciler deletes it. See DefaultReconcileGrace. grace time.Duration diff --git a/service/user.go b/service/user.go new file mode 100644 index 0000000000000000000000000000000000000000..bb374c968d0cc55d25bee720d21fa1d06d18435a --- /dev/null +++ b/service/user.go @@ -0,0 +1,21 @@ +package service + +import ( + "context" + "fmt" +) + +// EnsureOwnerUser seeds the owner's user row (username = the configured +// owner-name) and caches its id for the webhook auth bridge. Call once at +// startup. +func (s *Service) EnsureOwnerUser(ctx context.Context) error { + id, err := s.store.EnsureUser(ctx, s.cfg.Instance.OwnerName) + if err != nil { + return fmt.Errorf("service: ensure owner user: %w", err) + } + s.ownerUserID = id + return nil +} + +// OwnerUserID is the owner user row id, 0 until EnsureOwnerUser has run. +func (s *Service) OwnerUserID() int { return s.ownerUserID }