major fixes

This commit is contained in:
2026-08-22 18:51:17 +02:00
parent 0ff24b1534
commit 0c154254c3
36 changed files with 2212 additions and 42 deletions
@@ -0,0 +1,332 @@
package billing
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// ErrFeatureNotGrantable is returned when a permission update names a key the company
// owner is not allowed to toggle (shell chrome, plan-metering capabilities, always-on keys).
var ErrFeatureNotGrantable = errors.New("feature not grantable")
// alwaysGrantedPrefixes are never restrictable: the app shell must keep working for
// every member, and capability.* keys meter the plan (credits, SKU caps) rather than
// describe UI access — restricting them per member would silently break billing math.
var alwaysGrantedPrefixes = []string{"shell.", "capability."}
// alwaysGrantedKeys keep a restricted member out of a dead end: they can always reach
// the dashboard landing page and their own profile (to change password / sign out).
var alwaysGrantedKeys = map[string]bool{
"dashboard.overview": true,
"settings.profile": true,
}
// IsGrantableFeatureKey reports whether a company owner may toggle key per member.
func IsGrantableFeatureKey(key string) bool {
key = strings.TrimSpace(key)
if !IsKnownFeatureKey(key) {
return false
}
if alwaysGrantedKeys[key] {
return false
}
for _, prefix := range alwaysGrantedPrefixes {
if strings.HasPrefix(key, prefix) {
return false
}
}
return true
}
// GrantableFeatureKeys returns the catalog subset an owner may restrict, in catalog order.
func GrantableFeatureKeys() []string {
out := make([]string, 0, len(FeatureCatalogKeys))
for _, key := range FeatureCatalogKeys {
if IsGrantableFeatureKey(key) {
out = append(out, key)
}
}
return out
}
// MemberPermissionEntry is one grantable key for the owner-facing permission editor.
type MemberPermissionEntry struct {
Key string `json:"key"`
Section string `json:"section"`
// Parent is the nearest grantable ancestor key ("catalog.products" for
// "catalog.products.tab_error"), so the editor can nest children under it.
Parent string `json:"parent,omitempty"`
// PlanAllowed is false when the company's plan already denies the key — the
// editor shows it greyed out rather than pretending the owner can grant it.
PlanAllowed bool `json:"plan_allowed"`
}
// MemberPermissionCatalog is the payload for GET /api/team/permission-catalog.
type MemberPermissionCatalog struct {
Sections []MemberPermissionSection `json:"sections"`
}
// MemberPermissionSection groups grantable keys under one dashboard area.
type MemberPermissionSection struct {
ID string `json:"id"`
Entries []MemberPermissionEntry `json:"entries"`
}
// grantableParent returns the nearest ancestor key that is itself grantable.
func grantableParent(key string) string {
parts := strings.Split(key, ".")
for i := len(parts) - 1; i > 0; i-- {
candidate := strings.Join(parts[:i], ".")
if IsGrantableFeatureKey(candidate) {
return candidate
}
}
return ""
}
// PermissionCatalogForCompany lists the grantable keys grouped by section, annotated
// with what the company's own plan already allows.
func (s *Service) PermissionCatalogForCompany(ctx context.Context, companyID uuid.UUID) (MemberPermissionCatalog, error) {
caps, err := s.CapabilitiesForCompany(ctx, companyID)
if err != nil {
return MemberPermissionCatalog{}, err
}
bySection := map[string][]MemberPermissionEntry{}
order := make([]string, 0, len(FeatureSections))
for _, key := range GrantableFeatureKeys() {
section, _ := SectionOfFeature(key)
if section == "" {
section = "other"
}
if _, seen := bySection[section]; !seen {
order = append(order, section)
}
bySection[section] = append(bySection[section], MemberPermissionEntry{
Key: key,
Section: section,
Parent: grantableParent(key),
PlanAllowed: caps.Features != nil && caps.Features[key],
})
}
out := MemberPermissionCatalog{Sections: make([]MemberPermissionSection, 0, len(order))}
for _, section := range order {
out.Sections = append(out.Sections, MemberPermissionSection{ID: section, Entries: bySection[section]})
}
return out, nil
}
// SanitizeMemberPermissions normalizes an incoming overlay to its canonical form:
// only grantable keys survive, and only denials (false) are stored — an "allowed"
// entry is the absence of a key. Returns ErrFeatureNotGrantable on an unknown or
// protected key so a typo fails loudly instead of silently granting access.
func SanitizeMemberPermissions(in map[string]bool) (map[string]bool, error) {
out := map[string]bool{}
for rawKey, allowed := range in {
key := strings.TrimSpace(rawKey)
if key == "" {
continue
}
if allowed {
// Allow is the default; storing it would only add drift.
if !IsKnownFeatureKey(key) {
return nil, fmt.Errorf("%w: %s", ErrUnknownFeatureKey, key)
}
continue
}
if !IsGrantableFeatureKey(key) {
return nil, fmt.Errorf("%w: %s", ErrFeatureNotGrantable, key)
}
out[key] = false
}
return out, nil
}
// MemberDeniesFeature reports whether the overlay denies key, honouring parent-prefix
// denial (denying "catalog.products" also denies "catalog.products.tab_error").
// Mirrors effectiveFeature() in apps/web/src/lib/plan-capabilities.ts.
func MemberDeniesFeature(perms map[string]bool, key string) bool {
if len(perms) == 0 {
return false
}
if alwaysGrantedKeys[key] {
return false
}
for _, prefix := range alwaysGrantedPrefixes {
if strings.HasPrefix(key, prefix) {
return false
}
}
if denied, ok := perms[key]; ok && !denied {
return true
}
parts := strings.Split(key, ".")
for i := 1; i < len(parts); i++ {
ancestor := strings.Join(parts[:i], ".")
if denied, ok := perms[ancestor]; ok && !denied {
return true
}
}
return false
}
// ApplyMemberPermissions intersects a company feature map with a member overlay.
// Returns a new map (the input is left untouched) plus the sorted list of keys the
// overlay turned off — the UI uses that to say "restricted by your administrator"
// instead of "upgrade your plan".
func ApplyMemberPermissions(features map[string]bool, perms map[string]bool) (map[string]bool, []string) {
if len(perms) == 0 {
return features, nil
}
out := make(map[string]bool, len(features))
denied := make([]string, 0, len(perms))
for key, allowed := range features {
if allowed && MemberDeniesFeature(perms, key) {
out[key] = false
denied = append(denied, key)
continue
}
out[key] = allowed
}
sort.Strings(denied)
return out, denied
}
// FeatureETag exposes the feature-map digest so callers outside the package (the /me
// handler) can re-stamp it after narrowing a map with ApplyMemberPermissions.
func FeatureETag(features map[string]bool) string { return featureETag(features) }
// MemberPermissions returns the stored overlay for one company member.
//
// The owner (companies.owner_user_id) is never restricted — they are the one who edits
// these — so an owner always resolves to a nil overlay regardless of what is stored.
// Reads memberships directly (rather than through the auth package) so capability
// resolution stays a single self-contained query on the hot /me + /capabilities path.
func (s *Service) MemberPermissions(ctx context.Context, companyID, userID uuid.UUID) (map[string]bool, error) {
if s == nil || s.Pool == nil || companyID == uuid.Nil || userID == uuid.Nil {
return nil, nil
}
var raw []byte
var isOwner bool
err := s.Pool.QueryRow(ctx, `
SELECT COALESCE(m.permissions, '{}'::jsonb),
(c.owner_user_id IS NOT NULL AND c.owner_user_id = m.user_id) AS is_owner
FROM memberships m
JOIN companies c ON c.id = m.company_id
WHERE m.company_id = $1 AND m.user_id = $2`, companyID, userID).Scan(&raw, &isOwner)
if errors.Is(err, pgx.ErrNoRows) {
// Platform staff acting on a tenant have no membership row — unrestricted.
return nil, nil
}
if err != nil {
// Pre-migration (no permissions / owner_user_id column): treat as unrestricted
// rather than locking every member out of the dashboard.
if isUndefinedColumn(err) {
return nil, nil
}
return nil, err
}
if isOwner {
return nil, nil
}
return decodeMemberPermissions(raw)
}
func decodeMemberPermissions(raw []byte) (map[string]bool, error) {
if len(raw) == 0 {
return nil, nil
}
var parsed map[string]bool
if err := json.Unmarshal(raw, &parsed); err != nil {
// A hand-edited / legacy blob must not brick the dashboard.
return nil, nil
}
out := map[string]bool{}
for key, allowed := range parsed {
if !allowed && IsGrantableFeatureKey(key) {
out[key] = false
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// CapabilitiesForMember returns the company capabilities narrowed by the caller's
// per-member overlay. This is what the dashboard shell (nav, route guard, feature
// gates) reads, so restricting a member here hides the surface everywhere at once.
func (s *Service) CapabilitiesForMember(ctx context.Context, companyID, userID uuid.UUID) (Capabilities, error) {
caps, err := s.CapabilitiesForCompany(ctx, companyID)
if err != nil {
return caps, err
}
perms, err := s.MemberPermissions(ctx, companyID, userID)
if err != nil {
return caps, err
}
if len(perms) == 0 {
return caps, nil
}
features, denied := ApplyMemberPermissions(caps.Features, perms)
caps.Features = features
caps.MemberRestricted = true
caps.MemberDeniedFeatures = denied
caps.DisabledFeatures = mergeDisabledFeatures(caps.DisabledFeatures, denied)
// Re-stamp the ETag: two members of one company now have different maps, and a
// shared ETag would let one member's cached response satisfy another's request.
caps.FeatureETag = featureETag(features)
return caps, nil
}
func mergeDisabledFeatures(existing, extra []string) []string {
seen := make(map[string]bool, len(existing)+len(extra))
out := make([]string, 0, len(existing)+len(extra))
for _, list := range [][]string{existing, extra} {
for _, key := range list {
if seen[key] {
continue
}
seen[key] = true
out = append(out, key)
}
}
sort.Strings(out)
return out
}
// AssertFeaturesForMember fails closed on the first key denied by the plan, the global
// gates, or the member's own overlay.
func (s *Service) AssertFeaturesForMember(ctx context.Context, companyID, userID uuid.UUID, keys ...string) error {
if len(keys) == 0 {
return nil
}
if err := s.AssertFeatures(ctx, companyID, keys...); err != nil {
return err
}
perms, err := s.MemberPermissions(ctx, companyID, userID)
if err != nil {
return err
}
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if MemberDeniesFeature(perms, key) {
return fmt.Errorf("%w: %s", ErrMemberFeatureDenied, key)
}
}
return nil
}
// ErrMemberFeatureDenied separates "your administrator turned this off" from
// ErrFeatureDisabled ("your plan does not include this") so the UI can offer the right
// next step — ask an admin vs. upgrade.
var ErrMemberFeatureDenied = errors.New("member_feature_denied")