333 lines
11 KiB
Go
333 lines
11 KiB
Go
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")
|