From 64e4ac89d9d5529643c64e187dc2d6b277992363 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Fri, 24 Jul 2026 11:31:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(coreauth):=20owner=20user=20seed=20+=20aut?= =?UTF-8?q?hn=E2=86=92core-go=20AuthContext=20bridge=20(Phase=205a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compatibility shim that lets core-go's webhook engine run inside this single-owner, agent-aware service — spec keeps authn as its real auth. - db.EnsureUser + service.EnsureOwnerUser: seed and cache the owner's "user" row (the FK target core-go's user-scoped webhook model needs). Idempotent; run at startup. - coreauth.Derive/Context: map authn.Principal → auth.AuthContext. Owner and agent both become AUTH_INTERNAL (not COOKIE) deliberately — INTERNAL bypasses core-go's @access scope checks AND is accepted by webhooks.NewAuthConfig (which refuses cookie auth), which is what lets the single owner create webhooks. The agent identity rides in the payload, not the auth context; webhook management stays owner-gated in the resolvers. Factored as its own package so it lifts cleanly into a shared sr-ht-ext module later (deferred). Build + tests green. --- coreauth/coreauth.go | 38 +++++++++++++++++++ coreauth/coreauth_test.go | 78 +++++++++++++++++++++++++++++++++++++++ db/user.go | 22 +++++++++++ db/user_test.go | 38 +++++++++++++++++++ service/service.go | 6 +++ service/user.go | 21 +++++++++++ 6 files changed, 203 insertions(+) create mode 100644 coreauth/coreauth.go create mode 100644 coreauth/coreauth_test.go create mode 100644 db/user.go create mode 100644 db/user_test.go create mode 100644 service/user.go 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 @@ type Service struct { 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 }