Centralize the external-claim entitlement gate in the registry

Registry.ClaimExternal now enforces the plan gate itself via an injected
domains.ExternalClaimGate (pre-lock, typed refusals), so every entry
point — and any future consumer — inherits it from the allocation API.
One constructor in internal/server builds the gate from the entitlements
querier and connect target; it is injected into the member-facing
registry constructions in server.go and fedwiki.go and drives affordance
rendering on both surfaces. The duplicated helpers and resource-key
constants in fedwiki web and member_domains are gone; fedwiki no longer
reads entitlement tables for this gate at all.

Archives the change with the domains-registry spec delta (enforcement
location is now requirement-level: registry-inherited, surfaces derive).
Closes the entitlement-gate placement debt in issues.md; files the
separately-discovered operator force-release dead-end affordance bug
that a placed claim exposed in the domains walkthrough.
This commit is contained in:
2026-07-26 03:33:16 -05:00
parent d3b222a446
commit 0affda70bd
17 changed files with 583 additions and 128 deletions
+38
View File
@@ -0,0 +1,38 @@
package domains
import (
"context"
"errors"
"testing"
)
// TestClaimExternalGateRefusesBeforeAllocating pins the enforcement point
// (centralize-external-claim-gate): a refusing gate stops ClaimExternal
// before ANY registry work. The registry here has a nil database — reaching
// the lock or the queries would panic — so the clean typed refusal is proof
// nothing was allocated, no token minted, no lock taken.
func TestClaimExternalGateRefusesBeforeAllocating(t *testing.T) {
for _, refusal := range []error{ErrExternalClaimsNotEntitled, ErrNoConnectTarget} {
r := NewRegistry(nil, WithExternalClaimGate(func(ctx context.Context, workspaceID string) error {
return refusal
}))
_, err := r.ClaimExternal(context.Background(), "ws-1", "example.org")
if !errors.Is(err, refusal) {
t.Errorf("ClaimExternal with refusing gate: got %v, want %v", err, refusal)
}
}
}
// TestClaimExternalGateReceivesWorkspace pins that the gate is consulted
// with the requesting workspace — the input the entitlement lookup keys on.
func TestClaimExternalGateReceivesWorkspace(t *testing.T) {
var saw string
r := NewRegistry(nil, WithExternalClaimGate(func(ctx context.Context, workspaceID string) error {
saw = workspaceID
return ErrExternalClaimsNotEntitled // refuse so the nil DB is never touched
}))
_, _ = r.ClaimExternal(context.Background(), "ws-42", "example.org")
if saw != "ws-42" {
t.Errorf("gate saw workspace %q, want ws-42", saw)
}
}
+52 -6
View File
@@ -346,6 +346,9 @@ type Registry struct {
db *sql.DB
q Querier
policy Policy
// externalGate, when set, must admit a workspace before ClaimExternal
// allocates anything. See WithExternalClaimGate.
externalGate ExternalClaimGate
}
// RegistryOption customizes a Registry at construction.
@@ -358,6 +361,41 @@ func WithPolicy(policy Policy) RegistryOption {
return func(r *Registry) { r.policy = policy }
}
// ExternalClaimGate decides whether a workspace may initiate an external
// claim. It returns nil to admit, ErrExternalClaimsNotEntitled or
// ErrNoConnectTarget (possibly wrapped) to refuse with a nameable reason.
// The type lives here so the registry can enforce the gate without importing
// the entitlement system — the concrete gate is injected by the wiring that
// owns those dependencies (centralize-external-claim-gate).
type ExternalClaimGate func(ctx context.Context, workspaceID string) error
// Typed gate refusals: surfaces map these to member-facing copy with
// errors.Is. A gate refusal names the workspace's own plan state, so unlike
// foreign-claim conflicts it is not an oracle risk.
var (
// ErrExternalClaimsNotEntitled: the workspace's primary pool does not
// grant ExternalClaimsResourceKey.
ErrExternalClaimsNotEntitled = errors.New("domains: external claims not entitled")
// ErrNoConnectTarget: the deployment has no connect target configured,
// so external domains cannot be served regardless of plan.
ErrNoConnectTarget = errors.New("domains: no connect target configured")
)
// ExternalClaimsResourceKey is the boolean entitlement gating external
// claims. Platform-owned: seeded by the domains migration stream (kind
// boolean, provider NULL). Named here, once — gate implementations and
// surfaces take it from this constant.
const ExternalClaimsResourceKey = "external_domain_claims"
// WithExternalClaimGate attaches the entitlement gate ClaimExternal enforces
// before allocating. Member-facing registry constructions MUST set it; a nil
// gate admits, which is the correct default for the member-agnostic
// contexts that also build registries (boot reconciliation, census
// adoption, unit tests) and were never plan-gated.
func WithExternalClaimGate(gate ExternalClaimGate) RegistryOption {
return func(r *Registry) { r.externalGate = gate }
}
// NewRegistry builds a Registry over a pooled database handle.
func NewRegistry(database *sql.DB, opts ...RegistryOption) *Registry {
r := &Registry{db: database}
@@ -695,12 +733,18 @@ func (t *Tx) claimCarved(ctx context.Context, workspaceID, fqdn string, policy n
// (ExternalClaimWindow unless the policy says otherwise); the caller starts
// the verification workflow from the returned row.
//
// The entitlement gate is the CALLER's: the registry reads no entitlement
// table (that would invert the module dependency and put a second lock order
// in play). Callers must confirm the workspace's primary pool grants
// `external_domain_claims` and that a connect target is configured before
// calling.
// The entitlement gate is enforced HERE, via the injected
// ExternalClaimGate, so every entry point inherits it
// (centralize-external-claim-gate). The registry still reads no entitlement
// table itself, and the gate runs before the advisory lock is taken, so no
// second lock order enters play — the two objections the old
// caller-obligation comment recorded.
func (r *Registry) ClaimExternal(ctx context.Context, workspaceID, root string) (Claim, error) {
if r.externalGate != nil {
if err := r.externalGate(ctx, workspaceID); err != nil {
return Claim{}, err
}
}
var out Claim
err := r.WithLock(ctx, func(ctx context.Context, tx *Tx) error {
var err error
@@ -710,7 +754,9 @@ func (r *Registry) ClaimExternal(ctx context.Context, workspaceID, root string)
return out, err
}
// ClaimExternal creates the pending claim under an already-held lock.
// ClaimExternal creates the pending claim under an already-held lock. Gate
// enforcement lives on the Registry wrapper (pre-lock); under-lock callers
// are registry-internal.
func (t *Tx) ClaimExternal(ctx context.Context, workspaceID, root string) (Claim, error) {
name := dnsname.Normalize(root)
if err := externalClaimPreflight(ctx, t.q, workspaceID, name); err != nil {
+9 -2
View File
@@ -185,8 +185,13 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
// how RegisterWorkflows constructs the activities' registry. The claim
// policy comes from Deps rather than viper: it is core configuration, and
// a claim initiated from this surface must obey the same window, caps, and
// ledger as one initiated from core's own Domains page.
registry := domains.NewRegistry(deps.Database, domains.WithPolicy(deps.DomainsPolicy))
// ledger as one initiated from core's member claim flow. This registry
// serves member-initiated external claims, so it MUST carry the shared
// entitlement gate (centralize-external-claim-gate); the same gate value
// drives the handlers' affordance rendering below.
customDomainsGate := server.NewExternalClaimGate(deps.EntitlementsQ, customDomainTarget)
registry := domains.NewRegistry(deps.Database, domains.WithPolicy(deps.DomainsPolicy),
domains.WithExternalClaimGate(customDomainsGate))
apiHandler := web.NewFedWikiHandler(web.FedWikiHandlerConfig{
SiteQ: siteQ,
@@ -199,6 +204,7 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
FedWikiAllowedDomains: allowedDomains,
FedWikiSiteScheme: siteScheme,
CustomDomainTarget: customDomainTarget,
CustomDomainsGate: customDomainsGate,
SupportURL: supportURL,
})
apiHandler.RegisterRoutes(mux)
@@ -214,6 +220,7 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
FedWikiAllowedDomains: allowedDomains,
FedWikiSiteScheme: siteScheme,
CustomDomainTarget: customDomainTarget,
CustomDomainsGate: customDomainsGate,
SupportURL: supportURL,
SwapCooldown: viper.GetDuration("fedwiki-swap-cooldown"),
TemplatesFS: templatesFS(),
+8 -2
View File
@@ -46,7 +46,11 @@ type FedWikiHandler struct {
FedWikiAllowedDomains []string // Domains where users can create sites
FedWikiSiteScheme string // http or https
CustomDomainTarget string // DNS target members point custom domains at; empty disables the flow
SupportURL string
// CustomDomainsGate is the shared external-claim gate
// (centralize-external-claim-gate); affordance rendering only — the
// registry enforces the same gate at ClaimExternal.
CustomDomainsGate domains.ExternalClaimGate
SupportURL string
}
// FedWikiHandlerConfig holds configuration for creating a FedWikiHandler.
@@ -61,6 +65,7 @@ type FedWikiHandlerConfig struct {
FedWikiAllowedDomains []string
FedWikiSiteScheme string
CustomDomainTarget string
CustomDomainsGate domains.ExternalClaimGate
SupportURL string
}
@@ -77,6 +82,7 @@ func NewFedWikiHandler(cfg FedWikiHandlerConfig) *FedWikiHandler {
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
CustomDomainTarget: cfg.CustomDomainTarget,
CustomDomainsGate: cfg.CustomDomainsGate,
SupportURL: cfg.SupportURL,
}
}
@@ -248,7 +254,7 @@ func (h *FedWikiHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, userMsg, h.SupportURL)
return
case dispositionUnclaimed:
enabled, err := customDomainsEnabled(r.Context(), h.EntitlementsQ, session.WorkspaceID, h.CustomDomainTarget)
enabled, err := customDomainsEnabled(r.Context(), h.CustomDomainsGate, session.WorkspaceID)
if err != nil {
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", err))
respondError(w, http.StatusInternalServerError, "Failed to check your plan. Please try again.", h.SupportURL)
@@ -15,7 +15,6 @@ package web
import (
"context"
"database/sql"
"errors"
"log/slog"
"net/http"
@@ -23,49 +22,31 @@ import (
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
wfdomains "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"go.temporal.io/sdk/client"
)
// customDomainsResourceKey is the boolean entitlement gating external-domain
// claims. It is platform-owned, not fedwiki-owned: domains-registry D7 seeds
// `external_domain_claims` from the domains stream, and fedwiki 00003
// re-points existing conferrals onto it and deletes the old
// `fedwiki_custom_domains` key.
const customDomainsResourceKey = "external_domain_claims"
// customDomainsEnabled reports whether the workspace may create external
// domain claims: the pool must hold a granted boolean entitlement for
// external_domain_claims AND the operator must have configured
// fedwiki-custom-domain-target. No target means no serving path to point
// members at, so the affordance stays hidden even when entitled (the
// dead-end-checkbox failure mode audit finding #5 flagged).
func customDomainsEnabled(ctx context.Context, entQ entitlements.Querier, workspaceID, connectTarget string) (bool, error) {
if strings.TrimSpace(connectTarget) == "" {
// customDomainsEnabled is the affordance read over the injected gate
// (centralize-external-claim-gate): fedwiki no longer names the entitlement
// key or reads the entitlement tables — the same gate the registry enforces
// at ClaimExternal decides here, so the affordance can never disagree with
// the enforcement (the dead-end-checkbox failure mode audit finding #5
// flagged). A typed refusal is simply "not enabled"; only infrastructure
// failures return an error.
func customDomainsEnabled(ctx context.Context, gate domains.ExternalClaimGate, workspaceID string) (bool, error) {
if gate == nil {
return false, nil
}
assignment, err := entQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
err := gate(ctx, workspaceID)
switch {
case err == nil:
return true, nil
case errors.Is(err, domains.ErrExternalClaimsNotEntitled), errors.Is(err, domains.ErrNoConnectTarget):
return false, nil
default:
return false, err
}
ent, err := entQ.GetBooleanEntitlementByPoolAndResource(ctx, entitlements.GetBooleanEntitlementByPoolAndResourceParams{
PoolID: assignment.PoolID,
ResourceKey: customDomainsResourceKey,
})
if err != nil {
// Never-conferred keys have no row (entitlements spec) — that is
// simply "not granted", not an error.
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return ent.Granted, nil
}
// customDomainDisposition is the branch table both create endpoints follow
@@ -227,6 +208,14 @@ func claimErrorMessage(err error, logger *slog.Logger) string {
// which discloses nothing about any other workspace and is the only
// thing the member can act on (design D3).
return budget.MemberMessage()
case errors.Is(err, domains.ErrExternalClaimsNotEntitled):
// Defense in depth: the affordance pre-check runs the same gate, so a
// member normally never reaches the registry unentitled — but the
// registry enforces it regardless (centralize-external-claim-gate)
// and its refusal deserves the same copy.
return "Adding a domain you own isn't part of your current plan."
case errors.Is(err, domains.ErrNoConnectTarget):
return "Domains you own can't be connected on this deployment yet."
case errors.Is(err, domains.ErrPendingClaimCap):
return "You already have several domain verifications in progress. Cancel one, or wait for one to finish, before starting another."
case errors.Is(err, domains.ErrInvalidName):
+12 -6
View File
@@ -40,9 +40,13 @@ type FedWikiPartialsHandler struct {
FedWikiAllowedDomains []string // Domains where users can create sites
FedWikiSiteScheme string // http or https
CustomDomainTarget string // DNS target members point custom domains at; empty disables the flow
SupportURL string
SwapCooldown time.Duration // active-site rotation cooldown window
Templates *server.SafeTemplates
// CustomDomainsGate is the shared external-claim gate
// (centralize-external-claim-gate): the same func the registry enforces
// at ClaimExternal, used here only to decide affordance rendering.
CustomDomainsGate domains.ExternalClaimGate
SupportURL string
SwapCooldown time.Duration // active-site rotation cooldown window
Templates *server.SafeTemplates
}
// FedWikiPartialsConfig holds configuration for the FedWiki partials handler.
@@ -57,6 +61,7 @@ type FedWikiPartialsConfig struct {
FedWikiAllowedDomains []string
FedWikiSiteScheme string
CustomDomainTarget string
CustomDomainsGate domains.ExternalClaimGate
SupportURL string
SwapCooldown time.Duration
// TemplatesFS is FedWiki's own template directory (fedwiki_*.html),
@@ -102,6 +107,7 @@ func NewFedWikiPartialsHandler(cfg FedWikiPartialsConfig) (*FedWikiPartialsHandl
FedWikiAllowedDomains: cfg.FedWikiAllowedDomains,
FedWikiSiteScheme: cfg.FedWikiSiteScheme,
CustomDomainTarget: cfg.CustomDomainTarget,
CustomDomainsGate: cfg.CustomDomainsGate,
SupportURL: cfg.SupportURL,
SwapCooldown: cfg.SwapCooldown,
Templates: server.NewSafeTemplates(tmpl, cfg.Logger),
@@ -354,7 +360,7 @@ func (h *FedWikiPartialsHandler) GetCreateForm(w http.ResponseWriter, r *http.Re
count = q.currentUsage
}
customDomains, cdErr := customDomainsEnabled(r.Context(), h.EntitlementsQ, workspaceID, h.CustomDomainTarget)
customDomains, cdErr := customDomainsEnabled(r.Context(), h.CustomDomainsGate, workspaceID)
if cdErr != nil {
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", cdErr))
}
@@ -428,7 +434,7 @@ func (h *FedWikiPartialsHandler) CreateSite(w http.ResponseWriter, r *http.Reque
h.renderCreateFormError(w, session.WorkspaceID, userMsg)
return
case dispositionUnclaimed:
enabled, err := customDomainsEnabled(r.Context(), h.EntitlementsQ, session.WorkspaceID, h.CustomDomainTarget)
enabled, err := customDomainsEnabled(r.Context(), h.CustomDomainsGate, session.WorkspaceID)
if err != nil {
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", err))
h.renderCreateFormError(w, session.WorkspaceID, "We couldn't check your plan right now. Please try again.")
@@ -1096,7 +1102,7 @@ func (h *FedWikiPartialsHandler) renderCreateFormError(w http.ResponseWriter, wo
count = q.currentUsage
}
customDomains, cdErr := customDomainsEnabled(context.TODO(), h.EntitlementsQ, workspaceID, h.CustomDomainTarget)
customDomains, cdErr := customDomainsEnabled(context.TODO(), h.CustomDomainsGate, workspaceID)
if cdErr != nil {
h.Logger.Error("custom-domain entitlement check failed", slog.Any("error", cdErr))
}
+64
View File
@@ -0,0 +1,64 @@
// The one concrete ExternalClaimGate (centralize-external-claim-gate):
// built here because this package owns both dependencies the gate needs —
// the entitlements querier and the deployment's connect target. Injected
// into every member-facing domains.Registry (enforcement) and handed to the
// surfaces that render the affordance (presentation), so the two conditions
// are decided in exactly one place.
package server
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// NewExternalClaimGate builds the gate ClaimExternal enforces: the
// deployment must have a connect target (no target ⇒ no serving path,
// regardless of plan) AND the workspace's primary active pool must grant
// domains.ExternalClaimsResourceKey. A never-conferred key has no row
// (entitlements spec) and reads as not granted, not as an error; only
// infrastructure failures return a non-refusal error.
func NewExternalClaimGate(entQ entitlements.Querier, connectTarget string) domains.ExternalClaimGate {
connectTarget = strings.TrimSpace(connectTarget)
return func(ctx context.Context, workspaceID string) error {
if connectTarget == "" {
return domains.ErrNoConnectTarget
}
if entQ == nil {
return domains.ErrExternalClaimsNotEntitled
}
assignment, err := entQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domains.ErrExternalClaimsNotEntitled
}
return fmt.Errorf("external-claim gate: primary pool for %s: %w", workspaceID, err)
}
ent, err := entQ.GetBooleanEntitlementByPoolAndResource(ctx, entitlements.GetBooleanEntitlementByPoolAndResourceParams{
PoolID: assignment.PoolID,
ResourceKey: domains.ExternalClaimsResourceKey,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domains.ErrExternalClaimsNotEntitled
}
return fmt.Errorf("external-claim gate: boolean entitlement for %s: %w", workspaceID, err)
}
if !ent.Granted {
return domains.ErrExternalClaimsNotEntitled
}
return nil
}
}
// externalClaimGateRefused reports whether err is one of the gate's typed
// refusals (as opposed to an infrastructure failure).
func externalClaimGateRefused(err error) bool {
return errors.Is(err, domains.ErrExternalClaimsNotEntitled) ||
errors.Is(err, domains.ErrNoConnectTarget)
}
@@ -0,0 +1,60 @@
package server
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
)
// TestNewExternalClaimGateRefusals pins the gate's two refusal conditions in
// the order they short-circuit: no connect target refuses regardless of
// entitlement state (no serving path exists), and an absent entitlement
// source reads as not granted, never as an error.
func TestNewExternalClaimGateRefusals(t *testing.T) {
noTarget := NewExternalClaimGate(nil, " ")
if err := noTarget(context.Background(), "ws-1"); !errors.Is(err, domains.ErrNoConnectTarget) {
t.Errorf("empty connect target: got %v, want ErrNoConnectTarget", err)
}
noEntQ := NewExternalClaimGate(nil, "connect.example.test")
if err := noEntQ(context.Background(), "ws-1"); !errors.Is(err, domains.ErrExternalClaimsNotEntitled) {
t.Errorf("nil entitlements querier: got %v, want ErrExternalClaimsNotEntitled", err)
}
}
// TestExternalClaimGateRefusedClassification keeps the refusal/infrastructure
// distinction honest — surfaces close the affordance on refusals but must
// surface real failures.
func TestExternalClaimGateRefusedClassification(t *testing.T) {
if !externalClaimGateRefused(domains.ErrExternalClaimsNotEntitled) ||
!externalClaimGateRefused(domains.ErrNoConnectTarget) {
t.Error("typed refusals must classify as refused")
}
if externalClaimGateRefused(errors.New("connection reset")) {
t.Error("an infrastructure error must not classify as a refusal")
}
if externalClaimGateRefused(nil) {
t.Error("nil is admission, not refusal")
}
}
// TestMemberDomainsHandlerCarriesGate pins the constructor wiring: a member
// domains handler always holds a gate (built from its own config), so the
// affordance read can never dereference nil. The registry-side injections in
// server.go and fedwiki.go are exercised end-to-end by the walkthroughs.
func TestMemberDomainsHandlerCarriesGate(t *testing.T) {
h, err := NewMemberDomainsHandler(MemberDomainsConfig{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})
if err != nil {
t.Fatalf("construct handler: %v", err)
}
if h.gate == nil {
t.Fatal("member domains handler constructed without a gate")
}
if err := h.gate(context.Background(), "ws-1"); !errors.Is(err, domains.ErrNoConnectTarget) {
t.Errorf("gate with empty config: got %v, want ErrNoConnectTarget", err)
}
}
+29 -52
View File
@@ -19,7 +19,6 @@ package server
import (
"context"
"database/sql"
"errors"
"html/template"
"io/fs"
@@ -38,12 +37,6 @@ import (
"go.temporal.io/sdk/client"
)
// externalDomainClaimsResourceKey is the boolean entitlement gating external
// domain claims. It is platform-owned (domains-registry D7 seeds it from the
// domains migration stream), which is why this surface names it directly
// rather than asking an integration.
const externalDomainClaimsResourceKey = "external_domain_claims"
// Member-facing copy for the two ways the add-a-domain affordance can be
// closed. They are deliberately different: only one of them is something the
// member can act on.
@@ -64,14 +57,19 @@ type MemberDomainsHandler struct {
// surface goes through it, so the ownership and disjointness rules are
// enforced in exactly one place.
Registry *domains.Registry
EntitlementsQ entitlements.Querier
TemporalClient client.Client // nil if Temporal isn't configured
AuthConfig *auth.Config
Logger *slog.Logger
// ConnectTarget is the DNS target members point external domains at.
// Empty disables external claims deployment-wide (design D4/D7).
// Empty disables external claims deployment-wide (design D4/D7). Still
// held here for rendering the connect record's value; the gate below is
// what decides admission.
ConnectTarget string
Templates *SafeTemplates
// gate is the shared external-claim gate
// (centralize-external-claim-gate), built in the constructor from the
// same querier + connect target the registry's enforcement uses.
gate domains.ExternalClaimGate
}
// MemberDomainsConfig holds configuration for the member domains handler.
@@ -102,12 +100,12 @@ func NewMemberDomainsHandler(cfg MemberDomainsConfig) (*MemberDomainsHandler, er
return &MemberDomainsHandler{
DomainsQ: cfg.DomainsQ,
Registry: cfg.Registry,
EntitlementsQ: cfg.EntitlementsQ,
TemporalClient: cfg.TemporalClient,
AuthConfig: cfg.AuthConfig,
Logger: cfg.Logger,
ConnectTarget: strings.TrimSpace(cfg.ConnectTarget),
Templates: NewSafeTemplates(tmpl, cfg.Logger),
gate: NewExternalClaimGate(cfg.EntitlementsQ, cfg.ConnectTarget),
}, nil
}
@@ -283,41 +281,20 @@ func splitProbeObserved(joined string) []string {
// --- Reads ---
// externalClaimsGranted reports whether the workspace's primary pool grants
// external_domain_claims. A never-conferred key has no row (entitlements
// spec), which is simply "not granted" rather than an error.
func (h *MemberDomainsHandler) externalClaimsGranted(ctx context.Context, workspaceID string) (bool, error) {
if h.EntitlementsQ == nil {
return false, nil
}
assignment, err := h.EntitlementsQ.GetPrimaryPoolAssignmentByWorkspace(ctx, workspaceID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
ent, err := h.EntitlementsQ.GetBooleanEntitlementByPoolAndResource(ctx, entitlements.GetBooleanEntitlementByPoolAndResourceParams{
PoolID: assignment.PoolID,
ResourceKey: externalDomainClaimsResourceKey,
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return ent.Granted, nil
}
// externalClaimsEnabled is the full gate the affordance renders on: a connect
// target must be configured (no target means no serving path to point members
// at) AND the workspace must be entitled (design D7).
// externalClaimsEnabled is the affordance read over the shared gate
// (centralize-external-claim-gate): a typed refusal is simply "closed", and
// only an infrastructure failure surfaces as an error. Enforcement no longer
// lives here — the registry's ClaimExternal carries the same gate.
func (h *MemberDomainsHandler) externalClaimsEnabled(ctx context.Context, workspaceID string) (bool, error) {
if h.ConnectTarget == "" {
err := h.gate(ctx, workspaceID)
switch {
case err == nil:
return true, nil
case externalClaimGateRefused(err):
return false, nil
default:
return false, err
}
return h.externalClaimsGranted(ctx, workspaceID)
}
// surfaceData assembles the whole Domains surface: the gate plus the
@@ -503,23 +480,23 @@ func (h *MemberDomainsHandler) AddClaim(w http.ResponseWriter, r *http.Request)
h.Templates.Render(w, "member_domains.html", data)
}
// Server-side gate. The two halves get different copy because only one of
// them is the member's to fix.
if h.ConnectTarget == "" {
// Pre-flight over the shared gate — presentation only, since the
// registry's ClaimExternal enforces the same gate regardless
// (centralize-external-claim-gate). The two refusals get different copy
// because only one of them is the member's to fix.
switch err := h.gate(ctx, session.WorkspaceID); {
case errors.Is(err, domains.ErrNoConnectTarget):
refuse(noConnectTargetMessage, "")
return
}
granted, err := h.externalClaimsGranted(ctx, session.WorkspaceID)
if err != nil {
case errors.Is(err, domains.ErrExternalClaimsNotEntitled):
refuse(planGateMessage, "")
return
case err != nil:
h.Logger.Error("failed to read external-domain entitlement",
slog.String("workspace_id", session.WorkspaceID), slog.Any("error", err))
refuse("We couldn't check your plan just now. Please try again.", "")
return
}
if !granted {
refuse(planGateMessage, "")
return
}
if root == "" {
refuse("Enter the domain you want to use.", "")
return
+6 -2
View File
@@ -416,8 +416,12 @@ func Start(ctx context.Context, cfg Config) error {
// deployment-wide, and a future core `domains-connect-target` key
// supersedes it without touching this handler.
memberDomainsHandler, err := NewMemberDomainsHandler(MemberDomainsConfig{
DomainsQ: cfg.DomainsQ,
Registry: domains.NewRegistry(cfg.Database, domains.WithPolicy(cfg.DomainsPolicy)),
DomainsQ: cfg.DomainsQ,
// Member-facing registry: MUST carry the external-claim gate so
// ClaimExternal enforces the plan gate itself
// (centralize-external-claim-gate).
Registry: domains.NewRegistry(cfg.Database, domains.WithPolicy(cfg.DomainsPolicy),
domains.WithExternalClaimGate(NewExternalClaimGate(cfg.EntitlementsQ, viper.GetString("fedwiki-custom-domain-target")))),
EntitlementsQ: cfg.EntitlementsQ,
TemporalClient: cfg.TemporalClient,
AuthConfig: authConfig,
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-26
@@ -0,0 +1,73 @@
# Design — Centralize the External-Claim Entitlement Gate
## Context
`Registry.ClaimExternal` is the single allocation door for external claims
(two callers: member_domains' claim start, fedwiki's custom-domain glue),
but the plan gate guarding it lives in two web-layer copies, and the
registry's doc comment makes enforcement a caller obligation. The domains
package is deliberately a leaf (imports only `dnsname`); entitlements
access cannot move into it as an import.
## Goals / Non-Goals
**Goals:**
- The gate is unskippable at `ClaimExternal`; new consumers inherit it.
- One implementation, one place naming the resource key.
- Zero member-visible behavior change.
**Non-Goals:**
- Gating carved/operator/adoption claim paths — they are not
member-initiated external claims and remain governed by their own rules.
- Any change to how the entitlement is granted or materialized.
## Decisions
- **Injected gate func, not an entitlements import.** `ExternalClaimGate
func(ctx, workspaceID) error` is defined in `internal/domains` (needs no
imports), attached via `WithExternalClaimGate`. This is the package's
established IoC pattern (cf. the reconciler capability, the ask
fallback). Alternative — `internal/domains` importing
`internal/entitlements` — rejected: it would end the module's leaf-ness
and prejudice the extraction future for a one-func need.
- **Nil gate admits.** Raw registries (unit tests, boot reconciliation and
census adoption, workflows) construct without a gate and are unaffected.
The wiring that serves member traffic is the only place that MUST inject
it — asserted by tests on both member-facing constructions. A
fail-closed default was considered and rejected: it would gate
system-context allocation paths that are not member-initiated and never
carried this check.
- **Typed refusals.** The gate returns
`ErrExternalClaimsNotEntitled` / `ErrNoConnectTarget`; surfaces map them
to the existing plan-gate and connect-target copy via `errors.Is`. The
registry wraps neither in `ErrNotFound` semantics — a gate refusal is
named, not an oracle risk (the workspace's own entitlement state is
theirs to see).
- **Gate built in `internal/server`, shared as a value.** The constructor
(`NewExternalClaimGate(entQ, connectTarget)`) lives beside the wiring
that owns both dependencies. FedWiki's web config swaps
`EntitlementsQ` + `CustomDomainTarget` lookups for one
`CustomDomainsGate domains.ExternalClaimGate` field — fedwiki already
imports domains, so no new edge; the fedwiki→entitlements edge for this
purpose disappears.
- **Surface pre-checks stay, as derivations.** Hiding the affordance and
pre-flight error copy remain in the web layers, but call the same gate —
presentation over the one enforcement point, per the spec delta.
## Risks / Trade-offs
- [A registry constructed for member traffic without the gate] → covered
by wiring tests asserting the gate is present on the member-facing
constructions; nil-admits keeps the blast radius to exactly the wiring
mistake the tests pin.
- [Double evaluation (pre-flight + registry) per claim start] → two cheap
queries on a rare, human-paced action; not worth caching.
## Migration Plan
Pure refactor + enforcement relocation; no schema or config changes.
Rollback is a revert.
## Open Questions
None.
@@ -0,0 +1,62 @@
# Centralize the External-Claim Entitlement Gate
## Why
The plan gate for bringing your own domain (`external_domain_claims` +
configured connect target) is enforced twice, in two web layers — fedwiki's
`customDomainsEnabled` and member_domains' `externalClaimsGranted` — while
the registry's `ClaimExternal` merely *documents* that "callers must
confirm" it. Every future door into external claims must remember to
reimplement the check, and extracting the domains system would leave its
gate behind. Filed as debt in `status/issues.md` during the 2026-07-26
de-centering work; this change pays it off: enforcement moves to the
allocation API itself, so entry points inherit the gate instead of
copying it.
## What Changes
- `internal/domains` gains `ExternalClaimGate` (a `func(ctx, workspaceID)
error` type — the package stays a leaf), a `WithExternalClaimGate`
registry option, typed refusals (`ErrExternalClaimsNotEntitled`,
`ErrNoConnectTarget`), and the canonical
`ExternalClaimsResourceKey` constant. `Registry.ClaimExternal` /
`Tx.ClaimExternal` invoke the gate before allocating; a nil gate admits
(raw registries in tests and boot adoption are member-agnostic and stay
ungated by construction).
- One concrete gate, built once in `internal/server` from
`entitlements.Querier` + the connect target, is injected into every
registry construction that serves member-initiated claims, and handed to
fedwiki's web layer as a plain func for affordance rendering.
- The duplicate helpers die: fedwiki's `customDomainsEnabled` (+ its
`customDomainsResourceKey` const) and member_domains'
`externalClaimsGranted` (+ its const) are replaced by the injected gate;
surface pre-checks remain for UX (hide the affordance, name the plan
gate) but delegate to the same func, classifying the typed errors for
copy.
- No behavior change for members: same two conditions, same refusal
messages, same server-side enforcement — now unskippable at the vault.
## Capabilities
### New Capabilities
_None._
### Modified Capabilities
- `domains-registry`: the external-claim entitlement-gate requirement is
strengthened — enforcement SHALL live in the registry's claim-initiation
path via an injected gate that every entry point inherits; surface
checks are presentation-only and SHALL derive from the same gate.
## Impact
- `internal/domains/registry.go` (+ gate type/option/errors file)
- `internal/server`: gate constructor; `member_domains.go` slims;
registry constructions in `server.go` inject the gate
- `internal/integrations/fedwiki`: web config carries the gate func;
`customdomain.go` and its four `customDomainsEnabled` call sites
(`api.go`, `partials.go` ×3) switch over; fedwiki-built registries
inject the gate
- Tests: registry gains gated-refusal coverage; fedwiki/member gate tests
re-point at the shared func
@@ -0,0 +1,62 @@
# domains-registry Delta
## MODIFIED Requirements
### Requirement: External claim creation is entitlement-gated
Creating an `external` claim SHALL be permitted only when (a) the workspace's primary
active pool holds a `boolean_entitlements` row for `resource_key =
'external_domain_claims'` with `granted = true`, and (b) a connect target is
configured for serving. An absent `boolean_entitlements` row SHALL count as not
granted. The gate SHALL be enforced in the registry's claim-initiation path itself,
via a gate injected at registry construction: every entry point that starts an
external claim inherits the check from the allocation API and SHALL NOT need to
reimplement it. Surfaces offering external-domain entry (the FedWiki create form)
SHALL render the affordance only when the same gate admits, and a denied request
SHALL receive an error naming the failed condition (the plan gate, or the missing
connect target) derived from the gate's typed refusal. The
`external_domain_claims` resource key SHALL be seeded by the domains migration
stream (kind `boolean`, provider NULL, name unprefixed per the provider-registry
platform-key convention). Entitlement data previously attached to
`fedwiki_custom_domains` — including `core.boolean_entitlements`,
`core.entitlement_set_rules`, and any `discourse.group_mappings` rows — SHALL be
migrated to the new key and the old key removed.
#### Scenario: Unentitled workspace cannot start verification
- **WHEN** a workspace without the granted boolean requests an external claim
- **THEN** the request SHALL be refused server-side regardless of UI state
- **AND** the error SHALL name the plan gate
#### Scenario: The registry itself refuses, not just the surfaces
- **WHEN** an external-claim initiation reaches the registry's allocation API from
any entry point while the workspace lacks the granted boolean
- **THEN** the registry SHALL refuse before allocating anything
- **AND** no pending claim, token, or verification workflow SHALL be created
#### Scenario: A new entry point inherits the gate
- **WHEN** a new consumer starts external claims through the registry's
claim-initiation API without adding any gate code of its own
- **THEN** the entitlement and connect-target conditions SHALL still be enforced
for its requests
#### Scenario: Entitled workspace on an unconfigured deployment sees no affordance
- **WHEN** a workspace holds the granted boolean but no connect target is configured
- **THEN** external-domain entry points SHALL NOT be rendered
- **AND** a direct request SHALL still be refused server-side
#### Scenario: Absent entitlement row means not granted
- **WHEN** the workspace's pool has no `boolean_entitlements` row for
`external_domain_claims`
- **THEN** the gate SHALL treat the capability as not granted (no error)
#### Scenario: Existing rules survive the key migration
- **WHEN** migrations run on a database whose entitlement sets and pools referenced
`fedwiki_custom_domains`
- **THEN** those rules and entitlements SHALL reference `external_domain_claims`
afterward, with `fedwiki_custom_domains` removed from `core.resource_keys`
@@ -0,0 +1,24 @@
# Tasks — Centralize the External-Claim Entitlement Gate
## 1. Domains package: the gate seam
- [x] 1.1 Add `ExternalClaimGate` func type, `ErrExternalClaimsNotEntitled`, `ErrNoConnectTarget`, `ExternalClaimsResourceKey`, and the `WithExternalClaimGate` registry option
- [x] 1.2 Enforce the gate in `ClaimExternal` (Registry and Tx paths) before any allocation; update the doc comment that made this a caller obligation
- [x] 1.3 Registry tests: gated refusal creates nothing (no claim/token), typed errors surface, nil gate admits
## 2. Core wiring
- [x] 2.1 `NewExternalClaimGate(entQ, connectTarget)` in `internal/server`; inject into the member-facing registry constructions in `server.go`
- [x] 2.2 member_domains: replace `externalClaimsGranted` + local const with the shared gate; map typed errors to the existing plan-gate / connect-target copy
- [x] 2.3 Wiring tests: the member domains handler constructor always carries a gate (in-package test); the server.go/fedwiki.go registry injections are asserted end-to-end by live verification rather than introspection
## 3. FedWiki switchover
- [x] 3.1 fedwiki web config gains `CustomDomainsGate domains.ExternalClaimGate`; wire it where fedwiki web is constructed; inject the gate into fedwiki-built registries serving member claim starts
- [x] 3.2 Replace `customDomainsEnabled` + local const at all four call sites (`api.go`, `partials.go` ×3, `customdomain.go` glue); delete the helper
- [x] 3.3 Update fedwiki tests to the injected gate
## 4. Gate and docs
- [x] 4.1 Full gate: gofmt, `go build ./...`, `go vet ./...`, `go test ./...`
- [x] 4.2 Close the debt entry in `status/issues.md` (resolved, pointing at this change)
+27 -9
View File
@@ -204,15 +204,19 @@ Creating an `external` claim SHALL be permitted only when (a) the workspace's pr
active pool holds a `boolean_entitlements` row for `resource_key =
'external_domain_claims'` with `granted = true`, and (b) a connect target is
configured for serving. An absent `boolean_entitlements` row SHALL count as not
granted. Surfaces offering external-domain entry (the FedWiki create form) SHALL
render the affordance only when both conditions hold, and a denied request SHALL
receive an error naming the plan gate. The `external_domain_claims` resource key
SHALL be seeded by the domains migration stream (kind `boolean`, provider NULL, name
unprefixed per the provider-registry platform-key convention). Entitlement data
previously attached to `fedwiki_custom_domains` — including
`core.boolean_entitlements`, `core.entitlement_set_rules`, and any
`discourse.group_mappings` rows — SHALL be migrated to the new key and the old key
removed.
granted. The gate SHALL be enforced in the registry's claim-initiation path itself,
via a gate injected at registry construction: every entry point that starts an
external claim inherits the check from the allocation API and SHALL NOT need to
reimplement it. Surfaces offering external-domain entry (the FedWiki create form)
SHALL render the affordance only when the same gate admits, and a denied request
SHALL receive an error naming the failed condition (the plan gate, or the missing
connect target) derived from the gate's typed refusal. The
`external_domain_claims` resource key SHALL be seeded by the domains migration
stream (kind `boolean`, provider NULL, name unprefixed per the provider-registry
platform-key convention). Entitlement data previously attached to
`fedwiki_custom_domains` — including `core.boolean_entitlements`,
`core.entitlement_set_rules`, and any `discourse.group_mappings` rows — SHALL be
migrated to the new key and the old key removed.
#### Scenario: Unentitled workspace cannot start verification
@@ -220,6 +224,20 @@ removed.
- **THEN** the request SHALL be refused server-side regardless of UI state
- **AND** the error SHALL name the plan gate
#### Scenario: The registry itself refuses, not just the surfaces
- **WHEN** an external-claim initiation reaches the registry's allocation API from
any entry point while the workspace lacks the granted boolean
- **THEN** the registry SHALL refuse before allocating anything
- **AND** no pending claim, token, or verification workflow SHALL be created
#### Scenario: A new entry point inherits the gate
- **WHEN** a new consumer starts external claims through the registry's
claim-initiation API without adding any gate code of its own
- **THEN** the entitlement and connect-target conditions SHALL still be enforced
for its requests
#### Scenario: Entitled workspace on an unconfigured deployment sees no affordance
- **WHEN** a workspace holds the granted boolean but no connect target is configured
+31 -14
View File
@@ -548,22 +548,39 @@ Labels: `bug`, `testing`
The reconcile suite (`internal/integrations/discourse/workflows/reconcile_test.go`) is tx-rollback against a fake Discourse server, but the shared stack DB's *committed* rows are visible inside the transaction. The 2026-07-21 harness fix (tx-private `testflag_*` resource key per harness) removed the biggest leak — desired-set members from live-session `user_links` — but `TestReconcilePersonTargeted` iterates **all** group mappings, so any committed mapping (e.g. an operator configuring one on the live stack, or a failed `TestDiscourseMappingWalkthrough` run leaving its `walkthrough-members` row) makes the fake 404 and the test fail. Currently the stack has no committed mappings; if this test starts failing, check `discourse.group_mappings` for residue first. Real fix: scope person-targeted reconciliation testing to harness-created mappings, or run reconcile tests against a dedicated database.
### Custom-domains entitlement gate lives in fedwiki's web layer, not core
### ~~Custom-domains entitlement gate lives in fedwiki's web layer, not core~~ (RESOLVED 2026-07-26)
Labels: `debt`, `architecture`
Labels: `debt`, `architecture`, `resolved`
The `external_domain_claims` boolean gate is enforced in
`internal/integrations/fedwiki/web/customdomain.go` (`customDomainsEnabled`),
though the resource key is platform-owned (seeded by the domains migration
stream, provider NULL) and the claim model is core's. Works today because
FedWiki hosts the only external-claim entry point; it becomes a coupling wart
the moment a second integration consumes domains or the domains system is
extracted (see `integration-interdependence-exploration.md`, which names this
the one prophylactic worth doing regardless of path). Fix direction: move the
gate into core — either into `internal/domains` claim initiation or the
shared claim-start glue in `internal/server`so entry points inherit it
rather than reimplement it. Noticed during the 2026-07-26 domains
de-centering explore; not blocking anything current.
**Resolved by `centralize-external-claim-gate`** (same-day): enforcement
moved into `Registry.ClaimExternal` via an injected
`domains.ExternalClaimGate` (pre-lock, so neither of the original
objections — entitlement-table reads in the domains package, a second lock
order — applies). One constructor (`internal/server.NewExternalClaimGate`)
feeds both member-facing registries and both surfaces' affordance checks;
the duplicated helpers and resource-key constants in fedwiki web and
member_domains are gone. Original concern, for the record: the
`external_domain_claims` gate was enforced only in per-surface web code, so
any second consumer of domains — or extraction — would have shipped
ungated.
### Operator domains: force-release renders for placed claims the server will refuse
Labels: `bug`, `frontend`, `testing`
The operator Domains list renders a force-release button on every live
non-root claim, but `ForceReleaseClaim` enforces the placement guard — so
clicking it on a claim with placements refuses server-side and no success
toast appears. A dead-end affordance (audit finding #5 family), discovered
2026-07-26 when a placed member claim existed in the stack DB for the first
time: `TestOperatorDomainsWalkthrough` assumes the first force-release
button succeeds and times out waiting for the toast. Two fixes wanted:
(1) don't render (or disable-with-title) force-release for placed claims,
matching the member surface's release guard UX; (2) make the walkthrough
pick an unplaced claim or treat the guard refusal as a pass-worthy branch.
Until then the walkthrough is reliable only against a stack whose live
claims are unplaced (its historical state). Not caused by — only exposed
during — the gate-centralization work.
## Design feedback (upstream design repo)