// 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] } }