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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+62
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user