96 lines
2.8 KiB
Go
96 lines
2.8 KiB
Go
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()
|
||
|
|
}
|