Files
member-console/internal/server/external_claim_gate.go
T
cgalo5758 0affda70bd 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.
2026-07-26 03:33:16 -05:00

65 lines
2.4 KiB
Go

// 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)
}