template-monorepo/internal/model/permission/seed/catalog.go

278 lines
7.4 KiB
Go
Raw Normal View History

// 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"
"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.assign.write",
"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
}
idByName, err := loadCatalogIDIndex(ctx, perms)
if err != nil {
return nil, err
}
for _, tenantID := range opts.TenantIDs {
if err := seedTenantRoles(ctx, roles, rolePerms, tenantID, idByName, 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, err := perms.GetAll(ctx, nil)
if err != nil {
return nil, err
}
idByName := make(map[string]string, len(all))
for _, p := range all {
idByName[p.Name] = p.ID.Hex()
}
return idByName, nil
}
func seedTenantRoles(
ctx context.Context,
roles domrepo.RoleRepository,
rolePerms domrepo.RolePermissionRepository,
tenantID string,
idByName map[string]string,
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 := make([]string, 0, len(def.PermissionNames))
for _, name := range def.PermissionNames {
id, ok := idByName[name]
if !ok {
return fmt.Errorf("permission seed: catalog missing %q for role %s", name, def.Key)
}
permissionIDs = append(permissionIDs, id)
}
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
}