major fixes
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrMemberNotFound is returned when the target user has no membership in the company.
|
||||
var ErrMemberNotFound = errors.New("member not found")
|
||||
|
||||
// MemberPermissions returns the stored per-member access overlay (a sparse map of
|
||||
// dashboard feature key -> false). Empty means unrestricted.
|
||||
//
|
||||
// This is the raw stored value for the owner-facing editor; capability resolution for
|
||||
// a live request goes through billing.CapabilitiesForMember, which also ignores the
|
||||
// overlay for the company owner and intersects it with the plan.
|
||||
func (s *Service) MemberPermissions(ctx context.Context, companyID, userID uuid.UUID) (map[string]bool, error) {
|
||||
var raw []byte
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(permissions, '{}'::jsonb)
|
||||
FROM memberships
|
||||
WHERE company_id = $1 AND user_id = $2`, companyID, userID).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrMemberNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]bool{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetMemberPermissions replaces the overlay for one member. Callers must sanitize the
|
||||
// map first (billing.SanitizeMemberPermissions) — this layer only persists it.
|
||||
func (s *Service) SetMemberPermissions(ctx context.Context, companyID, userID uuid.UUID, perms map[string]bool) error {
|
||||
if perms == nil {
|
||||
perms = map[string]bool{}
|
||||
}
|
||||
encoded, err := json.Marshal(perms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
UPDATE memberships
|
||||
SET permissions = $3::jsonb, updated_at = now()
|
||||
WHERE company_id = $1 AND user_id = $2`, companyID, userID, encoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrMemberNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemberPermissionsByCompany returns every restricted member's overlay for one company,
|
||||
// keyed by user id. Members with an empty overlay are omitted, so the team list can show
|
||||
// a "restricted" badge without an N+1 query.
|
||||
func (s *Service) MemberPermissionsByCompany(ctx context.Context, companyID uuid.UUID) (map[uuid.UUID]map[string]bool, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT user_id, permissions
|
||||
FROM memberships
|
||||
WHERE company_id = $1 AND permissions <> '{}'::jsonb`, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uuid.UUID]map[string]bool{}
|
||||
for rows.Next() {
|
||||
var userID uuid.UUID
|
||||
var raw []byte
|
||||
if err := rows.Scan(&userID, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perms := map[string]bool{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &perms); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(perms) > 0 {
|
||||
out[userID] = perms
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -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")
|
||||
@@ -0,0 +1,181 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsGrantableFeatureKey(t *testing.T) {
|
||||
grantable := []string{
|
||||
"stores.hub",
|
||||
"settings.api_keys",
|
||||
"catalog.products",
|
||||
"catalog.products.tab_error",
|
||||
"marketing.campaigns.send",
|
||||
}
|
||||
for _, key := range grantable {
|
||||
if !IsGrantableFeatureKey(key) {
|
||||
t.Errorf("expected %q to be grantable", key)
|
||||
}
|
||||
}
|
||||
notGrantable := []string{
|
||||
"shell.navigation", // app chrome
|
||||
"capability.ai_credits", // plan metering, not UI access
|
||||
"dashboard.overview", // always-on landing page
|
||||
"settings.profile", // always-on: own password / sign out
|
||||
"catalog.not_a_real_key", // unknown
|
||||
"",
|
||||
}
|
||||
for _, key := range notGrantable {
|
||||
if IsGrantableFeatureKey(key) {
|
||||
t.Errorf("expected %q to NOT be grantable", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMemberPermissions(t *testing.T) {
|
||||
t.Run("keeps only denials of grantable keys", func(t *testing.T) {
|
||||
got, err := SanitizeMemberPermissions(map[string]bool{
|
||||
"stores.hub": false,
|
||||
"settings.api_keys": false,
|
||||
"catalog.products": true, // allow is the default — never stored
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]bool{"stores.hub": false, "settings.api_keys": false}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects protected keys instead of silently dropping them", func(t *testing.T) {
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"shell.navigation": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
||||
t.Fatalf("expected ErrFeatureNotGrantable, got %v", err)
|
||||
}
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"dashboard.overview": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
||||
t.Fatalf("expected ErrFeatureNotGrantable for always-on key, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects unknown keys", func(t *testing.T) {
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"nope.nope": false}); !errors.Is(err, ErrFeatureNotGrantable) {
|
||||
t.Fatalf("expected ErrFeatureNotGrantable, got %v", err)
|
||||
}
|
||||
if _, err := SanitizeMemberPermissions(map[string]bool{"nope.nope": true}); !errors.Is(err, ErrUnknownFeatureKey) {
|
||||
t.Fatalf("expected ErrUnknownFeatureKey, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMemberDeniesFeature(t *testing.T) {
|
||||
perms := map[string]bool{"stores.hub": false, "catalog.products": false}
|
||||
|
||||
if !MemberDeniesFeature(perms, "stores.hub") {
|
||||
t.Error("direct denial should apply")
|
||||
}
|
||||
if !MemberDeniesFeature(perms, "catalog.products.tab_error") {
|
||||
t.Error("denying a parent must deny its descendants")
|
||||
}
|
||||
if MemberDeniesFeature(perms, "catalog.categories") {
|
||||
t.Error("sibling keys must stay allowed")
|
||||
}
|
||||
if MemberDeniesFeature(nil, "stores.hub") {
|
||||
t.Error("an empty overlay denies nothing")
|
||||
}
|
||||
|
||||
// Protected keys survive even a hand-edited overlay that names them.
|
||||
hostile := map[string]bool{"shell.navigation": false, "dashboard.overview": false, "capability.ai_credits": false}
|
||||
for key := range hostile {
|
||||
if MemberDeniesFeature(hostile, key) {
|
||||
t.Errorf("%q must never be deniable", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMemberPermissions(t *testing.T) {
|
||||
features := map[string]bool{
|
||||
"stores.hub": true,
|
||||
"catalog.products": true,
|
||||
"catalog.products.tab_error": true,
|
||||
"marketing.campaigns": false, // already off by plan
|
||||
"dashboard.overview": true,
|
||||
}
|
||||
perms := map[string]bool{"stores.hub": false, "catalog.products": false, "marketing.campaigns": false}
|
||||
|
||||
got, denied := ApplyMemberPermissions(features, perms)
|
||||
|
||||
for _, key := range []string{"stores.hub", "catalog.products", "catalog.products.tab_error"} {
|
||||
if got[key] {
|
||||
t.Errorf("expected %q to be denied", key)
|
||||
}
|
||||
}
|
||||
if !got["dashboard.overview"] {
|
||||
t.Error("always-on keys must survive the overlay")
|
||||
}
|
||||
// Already-off plan keys are not reported as member denials — the member did not lose them.
|
||||
want := []string{"catalog.products", "catalog.products.tab_error", "stores.hub"}
|
||||
if !reflect.DeepEqual(denied, want) {
|
||||
t.Fatalf("denied = %v, want %v", denied, want)
|
||||
}
|
||||
// The input map must not be mutated — it is shared with the company-level cache.
|
||||
if !features["stores.hub"] {
|
||||
t.Error("ApplyMemberPermissions mutated its input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMemberPermissionsIsNoopWithoutOverlay(t *testing.T) {
|
||||
features := map[string]bool{"stores.hub": true}
|
||||
got, denied := ApplyMemberPermissions(features, nil)
|
||||
if len(denied) != 0 {
|
||||
t.Fatalf("expected no denials, got %v", denied)
|
||||
}
|
||||
if !reflect.DeepEqual(got, features) {
|
||||
t.Fatalf("expected the map unchanged, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantableFeatureKeysExcludeProtectedPrefixes(t *testing.T) {
|
||||
keys := GrantableFeatureKeys()
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("expected a non-empty grantable catalog")
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !IsGrantableFeatureKey(key) {
|
||||
t.Errorf("GrantableFeatureKeys returned non-grantable %q", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantableParent(t *testing.T) {
|
||||
if got := grantableParent("catalog.products.tab_error"); got != "catalog.products" {
|
||||
t.Errorf("got %q, want catalog.products", got)
|
||||
}
|
||||
// dashboard.overview is always-on, so its children have no grantable parent.
|
||||
if got := grantableParent("dashboard.stats"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
if got := grantableParent("stores.hub"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMemberPermissionsIsDefensive(t *testing.T) {
|
||||
// Garbage must not brick the dashboard — it resolves to "unrestricted".
|
||||
if got, _ := decodeMemberPermissions([]byte("not json")); got != nil {
|
||||
t.Errorf("expected nil for invalid JSON, got %v", got)
|
||||
}
|
||||
if got, _ := decodeMemberPermissions([]byte(`{}`)); got != nil {
|
||||
t.Errorf("expected nil for an empty overlay, got %v", got)
|
||||
}
|
||||
// Stale / protected keys are dropped at read time too.
|
||||
got, err := decodeMemberPermissions([]byte(`{"stores.hub":false,"shell.navigation":false,"gone.key":false,"feeds.list":true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]bool{"stores.hub": false}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ type Capabilities struct {
|
||||
DisabledFeatures []string `json:"disabled_features"`
|
||||
FeatureETag string `json:"feature_etag"`
|
||||
Entitlements Entitlements `json:"entitlements"`
|
||||
// MemberRestricted is true when the company owner narrowed this member's access.
|
||||
// MemberDeniedFeatures lists the keys the plan allows but the member overlay denies,
|
||||
// so the UI can say "ask your administrator" instead of "upgrade your plan".
|
||||
MemberRestricted bool `json:"member_restricted,omitempty"`
|
||||
MemberDeniedFeatures []string `json:"member_denied_features,omitempty"`
|
||||
}
|
||||
|
||||
// FeatureGatesUpdate is the PUT /api/admin/feature-gates body.
|
||||
|
||||
@@ -58,6 +58,10 @@ type CreditsOverview struct {
|
||||
Sections map[string]bool `json:"sections,omitempty"`
|
||||
DisabledFeatures []string `json:"disabled_features,omitempty"`
|
||||
FeatureETag string `json:"feature_etag,omitempty"`
|
||||
// Per-member access overlay (settings > team). Set by the /me handler so the
|
||||
// dashboard can say "ask your administrator" rather than "upgrade your plan".
|
||||
MemberRestricted bool `json:"member_restricted,omitempty"`
|
||||
MemberDeniedFeatures []string `json:"member_denied_features,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) CreditsOverview(ctx context.Context, companyID uuid.UUID, lowThreshold int) (CreditsOverview, error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -359,6 +360,17 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if credits, err := s.Billing.CreditsOverview(r.Context(), cid, s.Config.LowCreditsThreshold); err == nil {
|
||||
// /me seeds the dashboard capability matrix before /billing/capabilities
|
||||
// lands, so the per-member overlay must be applied here too — otherwise a
|
||||
// restricted member sees a flash of the full nav on every page load.
|
||||
if perms, perr := s.Billing.MemberPermissions(r.Context(), cid, uid); perr == nil && len(perms) > 0 {
|
||||
features, denied := billing.ApplyMemberPermissions(credits.Features, perms)
|
||||
credits.Features = features
|
||||
credits.DisabledFeatures = append(credits.DisabledFeatures, denied...)
|
||||
credits.FeatureETag = billing.FeatureETag(features)
|
||||
credits.MemberRestricted = true
|
||||
credits.MemberDeniedFeatures = denied
|
||||
}
|
||||
out["credits"] = credits
|
||||
}
|
||||
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil {
|
||||
|
||||
@@ -180,7 +180,13 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
// Restricted / DeniedCount summarise the owner-set access overlay so the team
|
||||
// table can badge restricted members without a request per row.
|
||||
Restricted bool `json:"restricted"`
|
||||
DeniedCount int `json:"denied_count"`
|
||||
}
|
||||
// Best effort: a missing permissions column (pre-migration) just means "no overlay".
|
||||
overlays, _ := s.Auth.MemberPermissionsByCompany(r.Context(), cid)
|
||||
out := make([]member, 0)
|
||||
for rows.Next() {
|
||||
var m member
|
||||
@@ -189,6 +195,10 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
m.IsOwner = ownerUserID != nil && *ownerUserID == m.UserID
|
||||
if !m.IsOwner {
|
||||
m.DeniedCount = len(deniedKeys(overlays[m.UserID]))
|
||||
m.Restricted = m.DeniedCount > 0
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
resp := map[string]any{"members": out, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
)
|
||||
|
||||
// apiFeatureRoutes maps dashboard API path prefixes to the feature key that governs
|
||||
// them, mirroring NAV_FEATURE_BY_HREF / GATED_ROUTES in
|
||||
// apps/web/src/lib/plan-capabilities.ts. Longest prefix wins.
|
||||
//
|
||||
// Only surfaces an owner can actually restrict appear here: hiding a nav entry in the
|
||||
// browser is cosmetic, so every hidden surface needs the matching API closed too.
|
||||
var apiFeatureRoutes = map[string]string{
|
||||
"/api/products": "catalog.products",
|
||||
"/api/categories": "catalog.categories",
|
||||
"/api/variables": "catalog.categories",
|
||||
"/api/attributes": "catalog.attributes",
|
||||
"/api/standard-fields": "catalog.standard_fields",
|
||||
"/api/field-groups": "catalog.standard_fields",
|
||||
"/api/structured-descriptions": "catalog.structured_descriptions",
|
||||
"/api/vector-categories": "catalog.vector_categories",
|
||||
"/api/feeds": "feeds.list",
|
||||
"/api/export-feeds": "feeds.export_feeds",
|
||||
"/api/files": "feeds.uploads",
|
||||
"/api/processing": "processing.monitor",
|
||||
"/api/woocommerce": "stores.woocommerce",
|
||||
"/api/shopify": "stores.shopify",
|
||||
"/api/campaigns": "marketing.campaigns",
|
||||
"/api/marketing/calendar": "marketing.content_calendar",
|
||||
"/api/seo": "marketing.seo",
|
||||
"/api/brand": "marketing.brand_kit",
|
||||
"/api/integrations/ai": "integrations.ai",
|
||||
"/api/integrations/email": "integrations.email",
|
||||
"/api/email/send": "integrations.email",
|
||||
"/api/api-keys": "settings.api_keys",
|
||||
"/api/team": "settings.team",
|
||||
"/api/company": "settings.company",
|
||||
"/api/support": "support.center",
|
||||
"/api/billing/checkout": "billing.checkout",
|
||||
"/api/billing/portal": "billing.customer_portal",
|
||||
"/api/billing/usage": "billing.overview",
|
||||
"/api/billing/plans": "billing.plans_compare",
|
||||
"/api/billing/credit-packs": "billing.overview",
|
||||
"/api/billing/stripe": "billing.overview",
|
||||
}
|
||||
|
||||
// apiFeatureRoutePrefixes is apiFeatureRoutes' keys sorted longest-first so
|
||||
// "/api/marketing/calendar" wins over a shorter overlapping prefix.
|
||||
var apiFeatureRoutePrefixes = func() []string {
|
||||
out := make([]string, 0, len(apiFeatureRoutes))
|
||||
for prefix := range apiFeatureRoutes {
|
||||
out = append(out, prefix)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
|
||||
return out
|
||||
}()
|
||||
|
||||
// featureKeyForAPIPath returns the governing feature key for a request path, or "".
|
||||
func featureKeyForAPIPath(path string) string {
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
for _, prefix := range apiFeatureRoutePrefixes {
|
||||
if path == prefix || strings.HasPrefix(path, prefix+"/") || strings.HasPrefix(path, prefix+"?") {
|
||||
return apiFeatureRoutes[prefix]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// alwaysReachableAPIPaths stay open regardless of the overlay: the shell needs them to
|
||||
// render at all, and a restricted member must still be able to read their own session,
|
||||
// wallet and capability matrix (that matrix is what tells the UI what to hide).
|
||||
var alwaysReachableAPIPaths = []string{
|
||||
"/api/billing/credits",
|
||||
"/api/billing/capabilities",
|
||||
}
|
||||
|
||||
// RequireMemberFeature enforces the company owner's per-member access overlay on the
|
||||
// dashboard API. Feature gating by plan stays where it is (billing.AssertFeature at the
|
||||
// handler level); this middleware adds the per-member layer so a hidden nav entry is
|
||||
// genuinely unreachable rather than just invisible.
|
||||
//
|
||||
// Denials return 403 with code "member_feature_denied" so the client can distinguish
|
||||
// "ask your administrator" from a plan upgrade prompt.
|
||||
func (s *Server) RequireMemberFeature(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
path := r.URL.Path
|
||||
for _, open := range alwaysReachableAPIPaths {
|
||||
if path == open {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
key := featureKeyForAPIPath(path)
|
||||
if key == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
cid, okCompany := CompanyIDFromContext(r.Context())
|
||||
uid, okUser := UserIDFromContext(r.Context())
|
||||
if !okCompany || !okUser {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
perms, err := s.Billing.MemberPermissions(r.Context(), cid, uid)
|
||||
if err != nil {
|
||||
// Fail open on a lookup error: a transient DB blip must not lock the whole
|
||||
// tenant out of their dashboard. The UI-level gate still applies.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if billing.MemberDeniesFeature(perms, key) {
|
||||
FieldError(w, http.StatusForbidden,
|
||||
"your administrator has turned off access to this area",
|
||||
"member_feature_denied", nil)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
)
|
||||
|
||||
func TestFeatureKeyForAPIPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/api/products", "catalog.products"},
|
||||
{"/api/products/", "catalog.products"},
|
||||
{"/api/products/8f14e45f-ceea-467a-9e0f-000000000000", "catalog.products"},
|
||||
{"/api/api-keys", "settings.api_keys"},
|
||||
{"/api/api-keys/42", "settings.api_keys"},
|
||||
{"/api/marketing/calendar", "marketing.content_calendar"},
|
||||
{"/api/marketing/calendar/prepare", "marketing.content_calendar"},
|
||||
{"/api/integrations/ai/prompts", "integrations.ai"},
|
||||
{"/api/integrations/email/test", "integrations.email"},
|
||||
{"/api/woocommerce/orders", "stores.woocommerce"},
|
||||
{"/api/billing/checkout", "billing.checkout"},
|
||||
// Unmapped paths stay open — the middleware must not guess.
|
||||
{"/api/auth/me", ""},
|
||||
{"/api/billing/credits", ""},
|
||||
{"/api/billing/capabilities", ""},
|
||||
{"/api/unknown-thing", ""},
|
||||
{"/api", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := featureKeyForAPIPath(tc.path); got != tc.want {
|
||||
t.Errorf("featureKeyForAPIPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A prefix must never match a longer sibling path segment: /api/feeds governs
|
||||
// feeds.list, but /api/feeds-something-else is not a feeds route.
|
||||
func TestFeatureKeyForAPIPathDoesNotMatchPartialSegments(t *testing.T) {
|
||||
if got := featureKeyForAPIPath("/api/feedsomething"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
if got := featureKeyForAPIPath("/api/products-export"); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Every key the middleware enforces must be one the owner can actually toggle,
|
||||
// otherwise a route could be blocked with no way to unblock it.
|
||||
func TestAPIFeatureRoutesAreGrantable(t *testing.T) {
|
||||
for path, key := range apiFeatureRoutes {
|
||||
if !billing.IsKnownFeatureKey(key) {
|
||||
t.Errorf("%s maps to unknown feature key %q", path, key)
|
||||
continue
|
||||
}
|
||||
if !billing.IsGrantableFeatureKey(key) {
|
||||
t.Errorf("%s maps to %q, which the owner cannot toggle", path, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The capability matrix itself must stay reachable — it is what tells the
|
||||
// dashboard which surfaces to hide.
|
||||
func TestAlwaysReachablePathsAreNotGated(t *testing.T) {
|
||||
for _, path := range alwaysReachableAPIPaths {
|
||||
if got := featureKeyForAPIPath(path); got != "" {
|
||||
t.Errorf("%s resolved to feature %q but must stay open", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GET /api/billing/capabilities — effective plan ∩ global features for the active company.
|
||||
// GET /api/billing/capabilities — effective plan ∩ global ∩ member features for the caller.
|
||||
func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
@@ -22,7 +22,10 @@ func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusUnauthorized, "company required")
|
||||
return
|
||||
}
|
||||
caps, err := s.Billing.CapabilitiesForCompany(r.Context(), cid)
|
||||
// Member-aware: the company owner's per-member overlay narrows what this caller
|
||||
// sees, so nav / route guard / feature gates all hide the same surfaces at once.
|
||||
uid, _ := UserIDFromContext(r.Context())
|
||||
caps, err := s.Billing.CapabilitiesForMember(r.Context(), cid, uid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load capabilities")
|
||||
return
|
||||
|
||||
@@ -436,6 +436,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Use(s.RequireSession)
|
||||
r.Use(s.RequireCompany)
|
||||
r.Use(s.RequireMemberFeature)
|
||||
r.Use(s.RateLimitMarketing)
|
||||
r.Use(s.RateLimitAIProbes)
|
||||
r.Use(s.RateLimitV1Process)
|
||||
@@ -453,8 +454,11 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/team/invites", s.handleListInvites)
|
||||
r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite)
|
||||
r.Post("/team/transfer-ownership", s.handleTransferOwnership)
|
||||
r.Get("/team/permission-catalog", s.handleGetPermissionCatalog)
|
||||
r.Patch("/team/{userID}", s.handleUpdateMemberRole)
|
||||
r.Delete("/team/{userID}", s.handleRemoveMember)
|
||||
r.Get("/team/{userID}/permissions", s.handleGetMemberPermissions)
|
||||
r.Put("/team/{userID}/permissions", s.handlePutMemberPermissions)
|
||||
|
||||
r.Get("/api-keys", s.handleListAPIKeys)
|
||||
r.Post("/api-keys", s.handleCreateAPIKey)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// memberPermissionsView is the payload for GET/PUT /api/team/{userID}/permissions.
|
||||
type memberPermissionsView struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
// IsOwner members are never restricted — the editor renders read-only for them.
|
||||
IsOwner bool `json:"is_owner"`
|
||||
// Denied lists the feature keys turned off for this member (sorted, sparse).
|
||||
Denied []string `json:"denied"`
|
||||
// Restricted is a convenience flag mirroring len(Denied) > 0.
|
||||
Restricted bool `json:"restricted"`
|
||||
}
|
||||
|
||||
// GET /api/team/permission-catalog — the grantable feature keys grouped by dashboard
|
||||
// section, annotated with what the company plan already allows.
|
||||
func (s *Server) handleGetPermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
if s.Billing == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "billing unavailable")
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
catalog, err := s.Billing.PermissionCatalogForCompany(r.Context(), cid)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "failed to load permission catalog")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "private, max-age=30, must-revalidate")
|
||||
JSON(w, http.StatusOK, catalog)
|
||||
}
|
||||
|
||||
// GET /api/team/{userID}/permissions
|
||||
func (s *Server) handleGetMemberPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.allowCompanyAdminOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
view, err := s.memberPermissionsView(r, cid, userID)
|
||||
if err != nil {
|
||||
writeMemberPermissionsErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// PUT /api/team/{userID}/permissions — owner (or platform admin) only.
|
||||
//
|
||||
// Body: {"denied": ["stores.hub", "settings.api_keys"]}. The list replaces the stored
|
||||
// overlay wholesale, so an empty list restores full (plan-limited) access.
|
||||
func (s *Server) handlePutMemberPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
// Owner-only on purpose: company admins can manage the team, but letting a
|
||||
// restricted admin edit permissions would let them lift their own restrictions.
|
||||
if !s.allowCompanyOwnerOrPlatform(w, r) {
|
||||
return
|
||||
}
|
||||
cid, _ := CompanyIDFromContext(r.Context())
|
||||
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Denied []string `json:"denied"`
|
||||
}
|
||||
if err := DecodeJSON(r, &body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
|
||||
isOwner, err := s.Auth.IsCompanyOwner(r.Context(), cid, userID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "lookup failed")
|
||||
return
|
||||
}
|
||||
if isOwner {
|
||||
Error(w, http.StatusConflict, "the company owner cannot be restricted")
|
||||
return
|
||||
}
|
||||
|
||||
incoming := make(map[string]bool, len(body.Denied))
|
||||
for _, key := range body.Denied {
|
||||
incoming[key] = false
|
||||
}
|
||||
perms, err := billing.SanitizeMemberPermissions(incoming)
|
||||
if err != nil {
|
||||
// Unknown / protected keys are a client mistake — echo which one failed.
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.Auth.SetMemberPermissions(r.Context(), cid, userID, perms); err != nil {
|
||||
writeMemberPermissionsErr(w, err)
|
||||
return
|
||||
}
|
||||
view, err := s.memberPermissionsView(r, cid, userID)
|
||||
if err != nil {
|
||||
writeMemberPermissionsErr(w, err)
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (s *Server) memberPermissionsView(r *http.Request, companyID, userID uuid.UUID) (memberPermissionsView, error) {
|
||||
var role, status string
|
||||
if err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT role, status FROM memberships
|
||||
WHERE company_id = $1 AND user_id = $2`, companyID, userID).Scan(&role, &status); err != nil {
|
||||
return memberPermissionsView{}, auth.ErrMemberNotFound
|
||||
}
|
||||
perms, err := s.Auth.MemberPermissions(r.Context(), companyID, userID)
|
||||
if err != nil {
|
||||
return memberPermissionsView{}, err
|
||||
}
|
||||
isOwner, err := s.Auth.IsCompanyOwner(r.Context(), companyID, userID)
|
||||
if err != nil {
|
||||
return memberPermissionsView{}, err
|
||||
}
|
||||
denied := deniedKeys(perms)
|
||||
if isOwner {
|
||||
denied = nil
|
||||
}
|
||||
return memberPermissionsView{
|
||||
UserID: userID,
|
||||
Role: auth.NormalizeMembershipRole(role),
|
||||
IsOwner: isOwner,
|
||||
Denied: denied,
|
||||
Restricted: len(denied) > 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deniedKeys flattens a stored overlay to the sorted list of turned-off keys,
|
||||
// dropping anything that is no longer grantable (catalog changes, hand edits).
|
||||
func deniedKeys(perms map[string]bool) []string {
|
||||
out := make([]string, 0, len(perms))
|
||||
for key, allowed := range perms {
|
||||
if !allowed && billing.IsGrantableFeatureKey(key) {
|
||||
out = append(out, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func writeMemberPermissionsErr(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, auth.ErrMemberNotFound) {
|
||||
Error(w, http.StatusNotFound, "member not found")
|
||||
return
|
||||
}
|
||||
Error(w, http.StatusInternalServerError, "permission update failed")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
-- +goose Up
|
||||
-- Per-member access overlay set by the company owner.
|
||||
--
|
||||
-- Sparse JSONB map of dashboard feature key -> false. Absent key means "inherit"
|
||||
-- (allowed, still subject to the plan). The overlay is purely restrictive: effective
|
||||
-- access = plan features ∩ global gates ∩ member overlay, so a member can never be
|
||||
-- granted more than the company's plan allows and no privilege escalation is possible.
|
||||
--
|
||||
-- The company owner (companies.owner_user_id) is never restricted — the owner is the
|
||||
-- one who edits these — so their row's overlay is ignored at resolve time.
|
||||
|
||||
ALTER TABLE memberships
|
||||
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
-- Partial index: only restricted members carry a non-empty overlay, and the common
|
||||
-- read is "does this member have any restriction at all".
|
||||
CREATE INDEX IF NOT EXISTS memberships_restricted_idx
|
||||
ON memberships (company_id, user_id)
|
||||
WHERE permissions <> '{}'::jsonb;
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS memberships_restricted_idx;
|
||||
ALTER TABLE memberships DROP COLUMN IF EXISTS permissions;
|
||||
Reference in New Issue
Block a user