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")