Files

96 lines
2.8 KiB
Go
Raw Permalink Normal View History

2026-08-22 18:51:17 +02:00
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()
}