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.
65 lines
2.4 KiB
Go
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)
|
|
}
|