355 lines
9.3 KiB
Go
355 lines
9.3 KiB
Go
// Package seed provides the embedded permission catalog and default
|
|
// system role set used by cmd/permission-seed and the test fixture.
|
|
package seed
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"gateway/internal/model/permission/domain/entity"
|
|
"gateway/internal/model/permission/domain/enum"
|
|
domrepo "gateway/internal/model/permission/domain/repository"
|
|
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
)
|
|
|
|
//go:embed catalog.json
|
|
var catalogJSON []byte
|
|
|
|
// CatalogEntry mirrors the JSON shape on disk. Parent / Description are
|
|
// optional; HTTPMethods + HTTPPath empty marks a category node.
|
|
type CatalogEntry struct {
|
|
Name string `json:"name"`
|
|
Parent string `json:"parent,omitempty"`
|
|
HTTPMethods string `json:"http_methods,omitempty"`
|
|
HTTPPath string `json:"http_path,omitempty"`
|
|
Type string `json:"type"`
|
|
Status string `json:"status,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// LoadCatalog returns the embedded catalog as parsed entries.
|
|
func LoadCatalog() ([]*CatalogEntry, error) {
|
|
var entries []*CatalogEntry
|
|
if err := json.Unmarshal(catalogJSON, &entries); err != nil {
|
|
return nil, fmt.Errorf("permission seed: parse catalog: %w", err)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// SystemRoleDefinition is a default role seeded for every B2B tenant on
|
|
// creation. PermissionNames are catalog entries by Name; the seeder
|
|
// resolves them to IDs at apply-time.
|
|
type SystemRoleDefinition struct {
|
|
Key string
|
|
DisplayName string
|
|
PermissionNames []string
|
|
}
|
|
|
|
// DefaultSystemRoles is the canonical set assigned to every new tenant
|
|
// per design §6.5. tenant_owner is undeletable; the rest can be edited.
|
|
var DefaultSystemRoles = []SystemRoleDefinition{
|
|
{
|
|
Key: "tenant_owner",
|
|
DisplayName: "Tenant Owner",
|
|
PermissionNames: []string{
|
|
"member.info.management",
|
|
"permission.role.management",
|
|
"system.management",
|
|
},
|
|
},
|
|
{
|
|
Key: "tenant_admin",
|
|
DisplayName: "Tenant Admin",
|
|
PermissionNames: []string{
|
|
"member.admin.list", "member.admin.read", "member.admin.update", "member.admin.status",
|
|
"permission.role.read", "permission.role.write", "permission.role.modify",
|
|
"permission.assign.write", "permission.assign.revoke",
|
|
"permission.mapping.write", "permission.policy.reload",
|
|
},
|
|
},
|
|
{
|
|
Key: "member_manager",
|
|
DisplayName: "Member Manager",
|
|
PermissionNames: []string{
|
|
"member.admin.list", "member.admin.read", "member.admin.update", "member.admin.status",
|
|
},
|
|
},
|
|
{
|
|
Key: "member",
|
|
DisplayName: "Member",
|
|
PermissionNames: []string{
|
|
"member.info.select",
|
|
"member.info.update",
|
|
},
|
|
},
|
|
{
|
|
Key: "viewer",
|
|
DisplayName: "Viewer",
|
|
PermissionNames: []string{
|
|
"member.info.select",
|
|
"permission.role.read",
|
|
},
|
|
},
|
|
}
|
|
|
|
// ApplyOptions tunes the seeder.
|
|
type ApplyOptions struct {
|
|
// TenantIDs receive the DefaultSystemRoles. Empty disables role seeding.
|
|
TenantIDs []string
|
|
// SkipCatalog skips the platform-wide upsert (for "tenant only" runs).
|
|
SkipCatalog bool
|
|
}
|
|
|
|
// Apply upserts the catalog and (optionally) the default system roles for
|
|
// the supplied tenants. Idempotent: re-running only updates fields that
|
|
// changed.
|
|
func Apply(
|
|
ctx context.Context,
|
|
perms domrepo.PermissionRepository,
|
|
roles domrepo.RoleRepository,
|
|
rolePerms domrepo.RolePermissionRepository,
|
|
opts ApplyOptions,
|
|
) (*Report, error) {
|
|
report := &Report{}
|
|
entries, err := LoadCatalog()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !opts.SkipCatalog {
|
|
if err := upsertCatalog(ctx, perms, entries, report); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if len(opts.TenantIDs) == 0 {
|
|
return report, nil
|
|
}
|
|
|
|
allPerms, permByName, err := loadCatalogIndex(ctx, perms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, tenantID := range opts.TenantIDs {
|
|
if err := seedTenantRoles(ctx, roles, rolePerms, tenantID, allPerms, permByName, report); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
// Report holds counters returned by Apply for CLI logging.
|
|
type Report struct {
|
|
CatalogUpserted int
|
|
RolesUpserted int
|
|
RolePermissionSet int
|
|
}
|
|
|
|
func upsertCatalog(
|
|
ctx context.Context,
|
|
perms domrepo.PermissionRepository,
|
|
entries []*CatalogEntry,
|
|
report *Report,
|
|
) error {
|
|
now := time.Now().UTC().UnixMilli()
|
|
|
|
// First pass: name → parent name. Second pass: resolve parent name to
|
|
// ID after every entry is upserted.
|
|
parentByName := make(map[string]string, len(entries))
|
|
for _, entry := range entries {
|
|
parentByName[entry.Name] = entry.Parent
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
permType := enum.PermissionType(entry.Type)
|
|
if permType == "" {
|
|
permType = enum.PermissionTypeBackendUser
|
|
}
|
|
status := enum.Status(entry.Status)
|
|
if status == "" {
|
|
status = enum.StatusOpen
|
|
}
|
|
perm := &entity.Permission{
|
|
Name: entry.Name,
|
|
HTTPMethods: entry.HTTPMethods,
|
|
HTTPPath: entry.HTTPPath,
|
|
Status: status,
|
|
Type: permType,
|
|
UpdateAt: now,
|
|
}
|
|
if err := perms.UpsertByName(ctx, perm); err != nil {
|
|
return fmt.Errorf("permission seed: upsert %s: %w", entry.Name, err)
|
|
}
|
|
report.CatalogUpserted++
|
|
}
|
|
|
|
idByName, err := loadCatalogIDIndex(ctx, perms)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, entry := range entries {
|
|
parentName, ok := parentByName[entry.Name]
|
|
if !ok || parentName == "" {
|
|
continue
|
|
}
|
|
parentID, ok := idByName[parentName]
|
|
if !ok {
|
|
return fmt.Errorf("permission seed: parent %q for %q not found", parentName, entry.Name)
|
|
}
|
|
perm := &entity.Permission{
|
|
Name: entry.Name,
|
|
Parent: parentID,
|
|
HTTPMethods: entry.HTTPMethods,
|
|
HTTPPath: entry.HTTPPath,
|
|
Status: enum.Status(entry.Status),
|
|
Type: enum.PermissionType(entry.Type),
|
|
UpdateAt: now,
|
|
}
|
|
if perm.Status == "" {
|
|
perm.Status = enum.StatusOpen
|
|
}
|
|
if perm.Type == "" {
|
|
perm.Type = enum.PermissionTypeBackendUser
|
|
}
|
|
if err := perms.UpsertByName(ctx, perm); err != nil {
|
|
return fmt.Errorf("permission seed: link parent %s: %w", entry.Name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadCatalogIDIndex(
|
|
ctx context.Context,
|
|
perms domrepo.PermissionRepository,
|
|
) (map[string]string, error) {
|
|
all, byName, err := loadCatalogIndex(ctx, perms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
idByName := make(map[string]string, len(all))
|
|
for name, perm := range byName {
|
|
idByName[name] = perm.ID.Hex()
|
|
}
|
|
return idByName, nil
|
|
}
|
|
|
|
func loadCatalogIndex(
|
|
ctx context.Context,
|
|
perms domrepo.PermissionRepository,
|
|
) ([]*entity.Permission, map[string]*entity.Permission, error) {
|
|
all, err := perms.GetAll(ctx, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
sort.SliceStable(all, func(i, j int) bool {
|
|
return all[i].Name < all[j].Name
|
|
})
|
|
byName := make(map[string]*entity.Permission, len(all))
|
|
for _, p := range all {
|
|
byName[p.Name] = p
|
|
}
|
|
return all, byName, nil
|
|
}
|
|
|
|
func seedTenantRoles(
|
|
ctx context.Context,
|
|
roles domrepo.RoleRepository,
|
|
rolePerms domrepo.RolePermissionRepository,
|
|
tenantID string,
|
|
allPerms []*entity.Permission,
|
|
permByName map[string]*entity.Permission,
|
|
report *Report,
|
|
) error {
|
|
for _, def := range DefaultSystemRoles {
|
|
role, err := roles.GetByKey(ctx, tenantID, def.Key)
|
|
if err != nil {
|
|
role = &entity.Role{
|
|
ID: bson.NewObjectID(),
|
|
TenantID: tenantID,
|
|
Key: def.Key,
|
|
DisplayName: def.DisplayName,
|
|
Status: enum.StatusOpen,
|
|
IsSystem: true,
|
|
}
|
|
if err := roles.Insert(ctx, role); err != nil {
|
|
return fmt.Errorf("permission seed: tenant=%s create role %s: %w", tenantID, def.Key, err)
|
|
}
|
|
report.RolesUpserted++
|
|
}
|
|
permissionIDs, err := resolveRolePermissionIDs(def, allPerms, permByName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := rolePerms.SetForRole(ctx, tenantID, role.ID.Hex(), permissionIDs); err != nil {
|
|
return fmt.Errorf("permission seed: tenant=%s set role perms %s: %w", tenantID, def.Key, err)
|
|
}
|
|
report.RolePermissionSet += len(permissionIDs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveRolePermissionIDs(
|
|
def SystemRoleDefinition,
|
|
allPerms []*entity.Permission,
|
|
permByName map[string]*entity.Permission,
|
|
) ([]string, error) {
|
|
childrenByParent := make(map[string][]*entity.Permission, len(allPerms))
|
|
parentByID := make(map[string]string, len(allPerms))
|
|
for _, perm := range allPerms {
|
|
id := perm.ID.Hex()
|
|
parentByID[id] = perm.Parent
|
|
childrenByParent[perm.Parent] = append(childrenByParent[perm.Parent], perm)
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(def.PermissionNames)*4)
|
|
for _, name := range def.PermissionNames {
|
|
perm, ok := permByName[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("permission seed: catalog missing %q for role %s", name, def.Key)
|
|
}
|
|
markWithParents(perm.ID.Hex(), parentByID, seen)
|
|
if !perm.IsLeaf() {
|
|
markDescendantLeaves(perm.ID.Hex(), childrenByParent, parentByID, seen)
|
|
}
|
|
}
|
|
|
|
out := make([]string, 0, len(seen))
|
|
for _, perm := range allPerms {
|
|
id := perm.ID.Hex()
|
|
if _, ok := seen[id]; ok {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func markDescendantLeaves(
|
|
parentID string,
|
|
childrenByParent map[string][]*entity.Permission,
|
|
parentByID map[string]string,
|
|
seen map[string]struct{},
|
|
) {
|
|
for _, child := range childrenByParent[parentID] {
|
|
if child.IsLeaf() {
|
|
markWithParents(child.ID.Hex(), parentByID, seen)
|
|
continue
|
|
}
|
|
markDescendantLeaves(child.ID.Hex(), childrenByParent, parentByID, seen)
|
|
}
|
|
}
|
|
|
|
func markWithParents(id string, parentByID map[string]string, seen map[string]struct{}) {
|
|
for id != "" {
|
|
if _, ok := seen[id]; ok {
|
|
return
|
|
}
|
|
seen[id] = struct{}{}
|
|
id = parentByID[id]
|
|
}
|
|
}
|