diff --git a/AGENTS.md b/AGENTS.md index 8368648ff..1b6b16c0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,8 @@ story/ # The stories/blogs while doing this project (AI cmd/ # Go services (entrypoint only: main.go + wire) note/ # Note service (nx: note) authorization/ # Authorization service (nx: authorization) + authorizationseed/ # Authorization seed: Casbin policy matrix (nx: authorizationseed) + authorizationfixtures/ # Authorization dev fixtures: memberships (nx: authorizationfixtures) billing/ # Billing service, Stripe (nx: billing) notefixturegen/ # Dev fixture generator (fake notes from Obsidian vault) billingseed/ # Billing seed: Stripe prices + portal config (nx: billingseed) @@ -40,6 +42,8 @@ cmd/ # Go services (entrypoint only: main.go + wire) internal/ # Internal Go packages (the actual service code) note/ authorization/ + authorizationseed/ + authorizationfixtures/ billing/ notefixturegen/ billingseed/ diff --git a/cmd/authorization/wire_gen.go b/cmd/authorization/wire_gen.go index 9f348643e..23eeb9e7b 100644 --- a/cmd/authorization/wire_gen.go +++ b/cmd/authorization/wire_gen.go @@ -121,7 +121,7 @@ func InitializeServer(ctx context.Context) (*authorization.Server, func(), error cleanup() return nil, nil, err } - healthHealth := health.New(db, serverConfig, kafka) + healthHealth := health.New(db, transactionalEnforcer, serverConfig, kafka) authorizationServer, err := authorization.NewServer(ctx, server, healthHealth, logger, global, general, appApp) if err != nil { cleanup6() diff --git a/cmd/authorizationfixtures/.env b/cmd/authorizationfixtures/.env new file mode 100644 index 000000000..6772f0f6a --- /dev/null +++ b/cmd/authorizationfixtures/.env @@ -0,0 +1,8 @@ +NOTOPIA_AUTHORIZATIONFIXTURES_GENERAL_APP_ENV="development" + +NOTOPIA_AUTHORIZATIONFIXTURES_LOG_LEVEL=debug +NOTOPIA_AUTHORIZATIONFIXTURES_LOG_PRETTY=true + +NOTOPIA_AUTHORIZATIONFIXTURES_DATABASE_URL=postgres://notopiauit:notopiauit@localhost:5439/authorization?sslmode=disable + +OTEL_SERVICE_NAME="authorizationfixtures" diff --git a/cmd/authorizationfixtures/.gitignore b/cmd/authorizationfixtures/.gitignore new file mode 100644 index 000000000..6da4e037f --- /dev/null +++ b/cmd/authorizationfixtures/.gitignore @@ -0,0 +1 @@ +authorizationfixtures diff --git a/cmd/authorizationfixtures/main.go b/cmd/authorizationfixtures/main.go new file mode 100644 index 000000000..f5dfdbacc --- /dev/null +++ b/cmd/authorizationfixtures/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/notopia-uit/notopia/pkg/logging" +) + +func main() { + if err := logging.FirstStart("NOTOPIA_AUTHORIZATIONFIXTURES_LOG_LEVEL"); err != nil { + slog.Error("failed to set up logger", slog.Any("error", err)) + os.Exit(1) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx); err != nil { + slog.Error("authorization fixtures failed", slog.Any("error", err)) + os.Exit(1) + } + slog.Info("authorization fixtures done") +} + +func run(ctx context.Context) error { + fixtures, cleanup, err := InitializeFixtures(ctx) + if err != nil { + return err + } + defer cleanup() + return fixtures.Run(ctx) +} diff --git a/cmd/authorizationfixtures/metadata.go b/cmd/authorizationfixtures/metadata.go new file mode 100644 index 000000000..026d8d633 --- /dev/null +++ b/cmd/authorizationfixtures/metadata.go @@ -0,0 +1,8 @@ +package main + +import "github.com/notopia-uit/notopia/pkg/metadata" + +const ( + ServiceName metadata.ServiceName = "notopia-authorizationfixtures" + ServiceVersion metadata.ServiceVersion = "v0.0.0" +) diff --git a/cmd/authorizationfixtures/project.json b/cmd/authorizationfixtures/project.json new file mode 100644 index 000000000..f79ad76a8 --- /dev/null +++ b/cmd/authorizationfixtures/project.json @@ -0,0 +1,56 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "authorizationfixtures", + "projectType": "application", + "tags": ["type:go"], + "namedInputs": { + "default": [ + "goPkgFiles", + "goFiles", + "{workspaceRoot}/internal/authorization/**/*.go", + "{workspaceRoot}/internal/authorizationfixtures/**/*.go" + ] + }, + "targets": { + "gen:wire": { + "executor": "nx:run-commands", + "outputs": ["{projectRoot}/wire_gen.go"], + "options": { + "command": "wire", + "cwd": "{projectRoot}" + }, + "cache": false + }, + "gen": { + "dependsOn": ["gen:wire"] + }, + "run": { + "executor": "nx:run-commands", + "options": { + "command": "go run ./cmd/authorizationfixtures/", + "cwd": "{workspaceRoot}", + "tty": true + }, + "dependsOn": ["gen"], + "continuous": false + }, + "build": { + "executor": "nx:run-commands", + "options": { + "command": "go build -o ./cmd/authorizationfixtures/authorizationfixtures ./cmd/authorizationfixtures/", + "cwd": "{workspaceRoot}" + }, + "dependsOn": ["gen"] + }, + "lint": { + "executor": "nx:run-commands", + "inputs": ["default", "goLint"], + "options": { + "command": "golangci-lint run ./cmd/authorizationfixtures/... ./internal/authorizationfixtures/...", + "cwd": "{workspaceRoot}" + }, + "parallelism": false + } + }, + "implicitDependencies": ["pkg", "internal/authorization"] +} diff --git a/cmd/authorizationfixtures/wire.go b/cmd/authorizationfixtures/wire.go new file mode 100644 index 000000000..b35674793 --- /dev/null +++ b/cmd/authorizationfixtures/wire.go @@ -0,0 +1,20 @@ +//go:build wireinject + +package main + +import ( + "context" + + "github.com/goforj/wire" + "github.com/notopia-uit/notopia/internal/authorizationfixtures" +) + +var ProviderSet = wire.NewSet( + authorizationfixtures.ProviderSet, + wire.Value(ServiceName), + wire.Value(ServiceVersion), +) + +func InitializeFixtures(ctx context.Context) (*authorizationfixtures.Fixtures, func(), error) { + panic(wire.Build(ProviderSet)) +} diff --git a/cmd/authorizationfixtures/wire_gen.go b/cmd/authorizationfixtures/wire_gen.go new file mode 100644 index 000000000..777fae395 --- /dev/null +++ b/cmd/authorizationfixtures/wire_gen.go @@ -0,0 +1,97 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/goforj/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package main + +import ( + "context" + "github.com/goforj/wire" + "github.com/notopia-uit/notopia/internal/authorization/app" + "github.com/notopia-uit/notopia/internal/authorization/infra" + "github.com/notopia-uit/notopia/internal/authorizationfixtures" + "github.com/notopia-uit/notopia/pkg/logging" + "github.com/notopia-uit/notopia/pkg/otel" +) + +// Injectors from wire.go: + +func InitializeFixtures(ctx context.Context) (*authorizationfixtures.Fixtures, func(), error) { + validate := authorizationfixtures.NewValidate() + viper := authorizationfixtures.NewViper() + config, err := authorizationfixtures.NewConfig(validate, viper) + if err != nil { + return nil, nil, err + } + sql := &config.Database + log := &config.Log + general := &config.General + stdoutHandler := logging.NewStdoutHandler(log, general) + serviceName := _wireServiceNameValue + serviceVersion := _wireServiceVersionValue + resource, err := otel.NewResource(ctx, serviceName, serviceVersion) + if err != nil { + return nil, nil, err + } + loggerProvider, cleanup, err := otel.NewLoggerProvider(ctx, resource) + if err != nil { + return nil, nil, err + } + slogHandler := otel.NewSlogHandler(serviceName, loggerProvider) + logger := logging.NewSlog(stdoutHandler, slogHandler, log) + meterProvider, cleanup2, err := otel.NewMeterProvider(ctx, resource) + if err != nil { + cleanup() + return nil, nil, err + } + tracerProvider, cleanup3, err := otel.NewTracerProvider(ctx, resource) + if err != nil { + cleanup2() + cleanup() + return nil, nil, err + } + textMapPropagator := otel.NewTextMapPropagator() + global := otel.ProvideGlobal(loggerProvider, meterProvider, tracerProvider, textMapPropagator) + db, cleanup4, err := infra.NewGORMDB(sql, logger, global) + if err != nil { + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } + adapter, err := infra.NewCasbinAdapter(db) + if err != nil { + cleanup4() + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } + slogLogger := infra.NewCasbinLogger(logger) + transactionalEnforcer, err := app.NewCasbinEnforcer(adapter, slogLogger) + if err != nil { + cleanup4() + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } + fixtures := authorizationfixtures.NewFixtures(transactionalEnforcer, logger) + return fixtures, func() { + cleanup4() + cleanup3() + cleanup2() + cleanup() + }, nil +} + +var ( + _wireServiceNameValue = ServiceName + _wireServiceVersionValue = ServiceVersion +) + +// wire.go: + +var ProviderSet = wire.NewSet(authorizationfixtures.ProviderSet, wire.Value(ServiceName), wire.Value(ServiceVersion)) diff --git a/cmd/authorizationseed/.env b/cmd/authorizationseed/.env new file mode 100644 index 000000000..7e8f19df6 --- /dev/null +++ b/cmd/authorizationseed/.env @@ -0,0 +1,8 @@ +NOTOPIA_AUTHORIZATIONSEED_GENERAL_APP_ENV="development" + +NOTOPIA_AUTHORIZATIONSEED_LOG_LEVEL=debug +NOTOPIA_AUTHORIZATIONSEED_LOG_PRETTY=true + +NOTOPIA_AUTHORIZATIONSEED_DATABASE_URL=postgres://notopiauit:notopiauit@localhost:5439/authorization?sslmode=disable + +OTEL_SERVICE_NAME="authorizationseed" diff --git a/cmd/authorizationseed/.gitignore b/cmd/authorizationseed/.gitignore new file mode 100644 index 000000000..d727a4e45 --- /dev/null +++ b/cmd/authorizationseed/.gitignore @@ -0,0 +1 @@ +authorizationseed diff --git a/cmd/authorizationseed/main.go b/cmd/authorizationseed/main.go new file mode 100644 index 000000000..fb8efe83f --- /dev/null +++ b/cmd/authorizationseed/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/notopia-uit/notopia/pkg/logging" +) + +func main() { + if err := logging.FirstStart("NOTOPIA_AUTHORIZATIONSEED_LOG_LEVEL"); err != nil { + slog.Error("failed to set up logger", slog.Any("error", err)) + os.Exit(1) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx); err != nil { + slog.Error("authorization seed failed", slog.Any("error", err)) + os.Exit(1) + } + slog.Info("authorization seed done") +} + +func run(ctx context.Context) error { + seeder, cleanup, err := InitializeSeeder(ctx) + if err != nil { + return err + } + defer cleanup() + return seeder.Run(ctx) +} diff --git a/cmd/authorizationseed/metadata.go b/cmd/authorizationseed/metadata.go new file mode 100644 index 000000000..8f1b46f95 --- /dev/null +++ b/cmd/authorizationseed/metadata.go @@ -0,0 +1,8 @@ +package main + +import "github.com/notopia-uit/notopia/pkg/metadata" + +const ( + ServiceName metadata.ServiceName = "notopia-authorizationseed" + ServiceVersion metadata.ServiceVersion = "v0.0.0" +) diff --git a/cmd/authorizationseed/project.json b/cmd/authorizationseed/project.json new file mode 100644 index 000000000..7fcc32d76 --- /dev/null +++ b/cmd/authorizationseed/project.json @@ -0,0 +1,56 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "authorizationseed", + "projectType": "application", + "tags": ["type:go"], + "namedInputs": { + "default": [ + "goPkgFiles", + "goFiles", + "{workspaceRoot}/internal/authorization/**/*.go", + "{workspaceRoot}/internal/authorizationseed/**/*.go" + ] + }, + "targets": { + "gen:wire": { + "executor": "nx:run-commands", + "outputs": ["{projectRoot}/wire_gen.go"], + "options": { + "command": "wire", + "cwd": "{projectRoot}" + }, + "cache": false + }, + "gen": { + "dependsOn": ["gen:wire"] + }, + "run": { + "executor": "nx:run-commands", + "options": { + "command": "go run ./cmd/authorizationseed/", + "cwd": "{workspaceRoot}", + "tty": true + }, + "dependsOn": ["gen"], + "continuous": false + }, + "build": { + "executor": "nx:run-commands", + "options": { + "command": "go build -o ./cmd/authorizationseed/authorizationseed ./cmd/authorizationseed/", + "cwd": "{workspaceRoot}" + }, + "dependsOn": ["gen"] + }, + "lint": { + "executor": "nx:run-commands", + "inputs": ["default", "goLint"], + "options": { + "command": "golangci-lint run ./cmd/authorizationseed/... ./internal/authorizationseed/...", + "cwd": "{workspaceRoot}" + }, + "parallelism": false + } + }, + "implicitDependencies": ["pkg", "internal/authorization"] +} diff --git a/cmd/authorizationseed/wire.go b/cmd/authorizationseed/wire.go new file mode 100644 index 000000000..945b71ea6 --- /dev/null +++ b/cmd/authorizationseed/wire.go @@ -0,0 +1,20 @@ +//go:build wireinject + +package main + +import ( + "context" + + "github.com/goforj/wire" + "github.com/notopia-uit/notopia/internal/authorizationseed" +) + +var ProviderSet = wire.NewSet( + authorizationseed.ProviderSet, + wire.Value(ServiceName), + wire.Value(ServiceVersion), +) + +func InitializeSeeder(ctx context.Context) (*authorizationseed.Seeder, func(), error) { + panic(wire.Build(ProviderSet)) +} diff --git a/cmd/authorizationseed/wire_gen.go b/cmd/authorizationseed/wire_gen.go new file mode 100644 index 000000000..aeb34082d --- /dev/null +++ b/cmd/authorizationseed/wire_gen.go @@ -0,0 +1,97 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/goforj/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package main + +import ( + "context" + "github.com/goforj/wire" + "github.com/notopia-uit/notopia/internal/authorization/app" + "github.com/notopia-uit/notopia/internal/authorization/infra" + "github.com/notopia-uit/notopia/internal/authorizationseed" + "github.com/notopia-uit/notopia/pkg/logging" + "github.com/notopia-uit/notopia/pkg/otel" +) + +// Injectors from wire.go: + +func InitializeSeeder(ctx context.Context) (*authorizationseed.Seeder, func(), error) { + validate := authorizationseed.NewValidate() + viper := authorizationseed.NewViper() + config, err := authorizationseed.NewConfig(validate, viper) + if err != nil { + return nil, nil, err + } + sql := &config.Database + log := &config.Log + general := &config.General + stdoutHandler := logging.NewStdoutHandler(log, general) + serviceName := _wireServiceNameValue + serviceVersion := _wireServiceVersionValue + resource, err := otel.NewResource(ctx, serviceName, serviceVersion) + if err != nil { + return nil, nil, err + } + loggerProvider, cleanup, err := otel.NewLoggerProvider(ctx, resource) + if err != nil { + return nil, nil, err + } + slogHandler := otel.NewSlogHandler(serviceName, loggerProvider) + logger := logging.NewSlog(stdoutHandler, slogHandler, log) + meterProvider, cleanup2, err := otel.NewMeterProvider(ctx, resource) + if err != nil { + cleanup() + return nil, nil, err + } + tracerProvider, cleanup3, err := otel.NewTracerProvider(ctx, resource) + if err != nil { + cleanup2() + cleanup() + return nil, nil, err + } + textMapPropagator := otel.NewTextMapPropagator() + global := otel.ProvideGlobal(loggerProvider, meterProvider, tracerProvider, textMapPropagator) + db, cleanup4, err := infra.NewGORMDB(sql, logger, global) + if err != nil { + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } + adapter, err := infra.NewCasbinAdapter(db) + if err != nil { + cleanup4() + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } + slogLogger := infra.NewCasbinLogger(logger) + transactionalEnforcer, err := app.NewCasbinEnforcer(adapter, slogLogger) + if err != nil { + cleanup4() + cleanup3() + cleanup2() + cleanup() + return nil, nil, err + } + seeder := authorizationseed.NewSeeder(transactionalEnforcer, logger) + return seeder, func() { + cleanup4() + cleanup3() + cleanup2() + cleanup() + }, nil +} + +var ( + _wireServiceNameValue = ServiceName + _wireServiceVersionValue = ServiceVersion +) + +// wire.go: + +var ProviderSet = wire.NewSet(authorizationseed.ProviderSet, wire.Value(ServiceName), wire.Value(ServiceVersion)) diff --git a/cmd/billingfixtures/wire_gen.go b/cmd/billingfixtures/wire_gen.go index 12af37dab..90af77e02 100644 --- a/cmd/billingfixtures/wire_gen.go +++ b/cmd/billingfixtures/wire_gen.go @@ -12,7 +12,6 @@ import ( "github.com/notopia-uit/notopia/internal/billing/infra/persistence" "github.com/notopia-uit/notopia/internal/billing/infra/persistence/pgrepo" "github.com/notopia-uit/notopia/internal/billingfixtures" - "github.com/notopia-uit/notopia/internal/billingfixtures/config" "github.com/notopia-uit/notopia/pkg/logging" "github.com/notopia-uit/notopia/pkg/otel" ) @@ -20,13 +19,13 @@ import ( // Injectors from wire.go: func InitializeFixtures(ctx context.Context) (*billingfixtures.Fixtures, func(), error) { - validate := config.NewValidate() - viper := config.NewViper() - configConfig, err := config.New(validate, viper) + validate := billingfixtures.NewValidate() + viper := billingfixtures.NewViper() + config, err := billingfixtures.NewConfig(validate, viper) if err != nil { return nil, nil, err } - sql := &configConfig.Database + sql := &config.Database serviceName := _wireServiceNameValue serviceVersion := _wireServiceVersionValue resource, err := otel.NewResource(ctx, serviceName, serviceVersion) @@ -59,8 +58,8 @@ func InitializeFixtures(ctx context.Context) (*billingfixtures.Fixtures, func(), } queries := persistence.NewSQLCQueries(pool) unitOfWork := pgrepo.NewUnitOfWork(pool, queries) - log := &configConfig.Log - general := &configConfig.General + log := &config.Log + general := &config.General stdoutHandler := logging.NewStdoutHandler(log, general) slogHandler := otel.NewSlogHandler(serviceName, loggerProvider) logger := logging.NewSlog(stdoutHandler, slogHandler, log) diff --git a/cmd/billingseed/wire_gen.go b/cmd/billingseed/wire_gen.go index c34f6d77f..13d7cdc48 100644 --- a/cmd/billingseed/wire_gen.go +++ b/cmd/billingseed/wire_gen.go @@ -15,7 +15,6 @@ import ( "github.com/notopia-uit/notopia/internal/billing/infra/persistence/pgreadmodel" "github.com/notopia-uit/notopia/internal/billing/infra/persistence/pgrepo" "github.com/notopia-uit/notopia/internal/billingseed" - "github.com/notopia-uit/notopia/internal/billingseed/config" "github.com/notopia-uit/notopia/pkg/logging" "github.com/notopia-uit/notopia/pkg/otel" ) @@ -23,13 +22,13 @@ import ( // Injectors from wire.go: func InitializeSeeder(ctx context.Context) (*billingseed.Seeder, func(), error) { - validate := config.NewValidate() - viper := config.NewViper() - configConfig, err := config.New(validate, viper) + validate := billingseed.NewValidate() + viper := billingseed.NewViper() + config, err := billingseed.NewConfig(validate, viper) if err != nil { return nil, nil, err } - sql := &configConfig.Database + sql := &config.Database serviceName := _wireServiceNameValue serviceVersion := _wireServiceVersionValue resource, err := otel.NewResource(ctx, serviceName, serviceVersion) @@ -61,8 +60,8 @@ func InitializeSeeder(ctx context.Context) (*billingseed.Seeder, func(), error) return nil, nil, err } db := persistence.NewPgxPoolStdlib(pool) - log := &configConfig.Log - general := &configConfig.General + log := &config.Log + general := &config.General stdoutHandler := logging.NewStdoutHandler(log, general) slogHandler := otel.NewSlogHandler(serviceName, loggerProvider) logger := logging.NewSlog(stdoutHandler, slogHandler, log) @@ -87,12 +86,12 @@ func InitializeSeeder(ctx context.Context) (*billingseed.Seeder, func(), error) ensureDefaultsHandler := app.NewEnsureDefaultsHandler(unitOfWork) billingReadModel := pgreadmodel.NewBillingReadModel(queries) listPricesHandler := app.NewListPricesHandler(billingReadModel) - configStripe := &configConfig.Stripe - stripe2 := billingseed.NewBillingStripeConfig(configStripe) - catalog := &configConfig.Catalog + billingseedStripe := &config.Stripe + configStripe := billingseed.NewBillingStripeConfig(billingseedStripe) + catalog := &config.Catalog billing := billingseed.NewBillingConfig(catalog) - stripeProvider := stripe.New(stripe2, billing) - identity := &configConfig.Identity + stripeProvider := stripe.New(configStripe, billing) + identity := &config.Identity settings := billingseed.NewAppSettings(catalog, identity) createPriceHandler := app.NewCreatePriceHandler(unitOfWork, stripeProvider, settings) ensurePortalConfigurationHandler := app.NewEnsurePortalConfigurationHandler(unitOfWork, stripeProvider, settings) diff --git a/internal/authorization/AGENTS.md b/internal/authorization/AGENTS.md index 2b7ceedf8..39805872c 100644 --- a/internal/authorization/AGENTS.md +++ b/internal/authorization/AGENTS.md @@ -7,11 +7,12 @@ Entrypoint: `cmd/authorization/`. Contract: `proto/authorization/authorization.p - `app/` — flat, one file per RPC (`createworkspace.go`, `hasworkspacepermission.go`, …), each with a `_test.go`. No `cmd_`/`q_` prefixes here, unlike `internal/note/`. -- `app/casbin.go`, `policysync.go` — enforcer setup and policy syncing +- `app/casbin.go` — enforcer setup +- `policy/` — embedded `model.conf`, `policy.csv`, `policy_fixtures.csv`; `Seed`, `LoadFixtures`, `DetectDrift` - `infra/` — `db.go` (Casbin postgres adapter), `casbin.go`, `integrationevent.go` - `controller/grpc/` — RPC handlers; `errs/` → gRPC status in `controller/grpc/err.go` -## Casbin model (`app/model.conf`) +## Casbin model (`policy/model.conf`) Request is `sub, dom, obj, act` = user, workspace, object type, action. @@ -31,12 +32,22 @@ Two grouping relations, easy to confuse: over `workspace` and `workspace_item`. It is static config: change it to change what a role can do; it is not per-tenant data. -`policy.csv` is the single source of truth for the static matrix (`p` + `g2`). `BootStrapPolicies` -syncs it on every start: adds missing rows and prunes stale `p` rows from the DB. -`policy_fixtures.csv` holds dev memberships (`g`), add-only, loaded by `LoadFixtures` only when -`AppEnv` is development; tests reuse it. DB `g` rows are runtime memberships and are never pruned. -Sync parses CSV into a standalone model and applies rules to the real enforcer; never build a second -enforcer on the shared model, it replaces the role manager and drops loaded `g` links. +`policy.csv` is the single source of truth for the static matrix (`p` + `g2`). `policy.Seed` syncs it: +adds missing rows and prunes stale `p` rows from the DB. `policy_fixtures.csv` holds dev memberships +(`g`), add-only, loaded by `policy.LoadFixtures`; tests reuse it. DB `g` rows are runtime memberships +and are never pruned. Sync parses CSV into a standalone model and applies rules to the real enforcer; +never build a second enforcer on the shared model, it replaces the role manager and drops loaded `g` links. + +The service itself never seeds or loads fixtures. Seeding is done by separate binaries sharing the +authorization infra (DB, Casbin adapter): `cmd/authorizationseed` (`internal/authorizationseed`, runs +`policy.Seed`, `just seed-authorization`) and `cmd/authorizationfixtures` +(`internal/authorizationfixtures`, runs `policy.LoadFixtures`, `just fixtures-authorization`). Nothing +is auto-loaded in development. + +At startup the service calls `policy.DetectDrift` once (every `p`/`g2` rule of `policy.csv` absent from +the in-memory policy, or any `p` rule in it that `policy.csv` does not contain). On drift it logs an error and keeps running instead of crashing. The +`policy` startup health check (`controller/health`) fails with the same condition, so the pod stays not +ready. The check is in-memory on purpose: the enforcer does not watch the DB, so only a restart picks up a seed. Note `editor` may `delete` a `workspace_item` but only `read` the `workspace` itself. @@ -45,10 +56,10 @@ Billing permissions on `workspace`: `billing_read` (owner, editor, viewer) and ## Adding a permission or role -1. Add the `p` lines to `app/policy.csv` (and a `g2` line if it's a new object type) +1. Add the `p` lines to `policy/policy.csv` (and a `g2` line if it's a new object type) 2. Extend the enum in `proto/authorization/authorization.proto`, run `nx gen proto` 3. Map the enum in `controller/grpc/service.go` and in each caller's `svc_authorization.go` -4. `app/model_test.go` and `policy_fixtures.csv` cover the matrix — extend them +4. `app/model_test.go` and `policy/policy_fixtures.csv` cover the matrix — extend them ## Consumers diff --git a/internal/authorization/app/app.go b/internal/authorization/app/app.go index 54ab0f936..5883b604f 100644 --- a/internal/authorization/app/app.go +++ b/internal/authorization/app/app.go @@ -1,8 +1,6 @@ package app import ( - "context" - "fmt" "log/slog" "github.com/casbin/casbin/v3" @@ -85,27 +83,3 @@ type App struct { Cmds *Cmds Queries *Queries } - -func (a *App) BootStrapPolicies(ctx context.Context) error { - slog.DebugContext(ctx, "BootStrapPolicies: adding permission policies") - - if err := syncPolicies(a.Enforcer, PolicyCSV, true); err != nil { - slog.ErrorContext(ctx, "BootStrapPolicies: failed to add policies", slog.Any("error", err)) - return fmt.Errorf("failed to bootstrap policies: %w", err) - } - - slog.InfoContext(ctx, "BootStrapPolicies: permission policies added") - return nil -} - -func (a *App) LoadFixtures(ctx context.Context) error { - slog.DebugContext(ctx, "LoadFixtures: loading dev fixture policies") - - if err := syncPolicies(a.Enforcer, PolicyFixturesCSV, false); err != nil { - slog.ErrorContext(ctx, "LoadFixtures: failed to load fixture policies", slog.Any("error", err)) - return fmt.Errorf("failed to load dev fixture policies: %w", err) - } - - slog.InfoContext(ctx, "LoadFixtures: dev fixture policies loaded") - return nil -} diff --git a/internal/authorization/app/casbin.go b/internal/authorization/app/casbin.go index f6dc62787..17107de54 100644 --- a/internal/authorization/app/casbin.go +++ b/internal/authorization/app/casbin.go @@ -1,30 +1,21 @@ package app import ( - _ "embed" "fmt" "github.com/casbin/casbin/v3" "github.com/casbin/casbin/v3/log" "github.com/casbin/casbin/v3/model" + "github.com/notopia-uit/notopia/internal/authorization/policy" ) -//go:embed model.conf -var ModelConf string - -//go:embed policy.csv -var PolicyCSV string - -//go:embed policy_fixtures.csv -var PolicyFixturesCSV string - type CasbinAdapter any func NewCasbinEnforcer( adapter CasbinAdapter, log log.Logger, ) (*casbin.TransactionalEnforcer, error) { - model, err := model.NewModelFromString(ModelConf) + model, err := model.NewModelFromString(policy.ModelConf) if err != nil { return nil, fmt.Errorf("failed to load Casbin model: %w", err) } diff --git a/internal/authorization/app/common_test.go b/internal/authorization/app/common_test.go index 0ffcfa8df..b003dba43 100644 --- a/internal/authorization/app/common_test.go +++ b/internal/authorization/app/common_test.go @@ -1,7 +1,6 @@ package app_test import ( - "context" _ "embed" "fmt" "testing" @@ -11,7 +10,7 @@ import ( stringadapter "github.com/casbin/casbin/v3/persist/string-adapter" gormadapter "github.com/casbin/gorm-adapter/v3" "github.com/glebarez/sqlite" - "github.com/notopia-uit/notopia/internal/authorization/app" + "github.com/notopia-uit/notopia/internal/authorization/policy" "gorm.io/gorm" ) @@ -27,7 +26,7 @@ func GetLocalEnforcer(t testing.TB, params *GetLocalEnforcerParams) (*casbin.Tra params = &GetLocalEnforcerParams{} } - m, err := model.NewModelFromString(app.ModelConf) + m, err := model.NewModelFromString(policy.ModelConf) if err != nil { return nil, fmt.Errorf("failed to load Casbin model: %w", err) } @@ -58,13 +57,11 @@ func GetLocalEnforcer(t testing.TB, params *GetLocalEnforcerParams) (*casbin.Tra return nil, fmt.Errorf("failed to create Casbin enforcer: %w", err) } - a := &app.App{Enforcer: enforcer} - ctx := context.Background() - if err := a.BootStrapPolicies(ctx); err != nil { - return nil, fmt.Errorf("failed to bootstrap policies: %w", err) + if err := policy.Seed(enforcer); err != nil { + return nil, err } if params.LoadTestPolicies { - if err := a.LoadFixtures(ctx); err != nil { + if err := policy.LoadFixtures(enforcer); err != nil { return nil, fmt.Errorf("failed to load fixtures: %w", err) } } @@ -73,11 +70,11 @@ func GetLocalEnforcer(t testing.TB, params *GetLocalEnforcerParams) (*casbin.Tra } // Use string adapter (non-transactional) - policy := app.PolicyCSV + rules := policy.CSV if params.LoadTestPolicies { - policy += "\n" + app.PolicyFixturesCSV + rules += "\n" + policy.FixturesCSV } - adapter := stringadapter.NewAdapter(policy) + adapter := stringadapter.NewAdapter(rules) enforcer, err := casbin.NewTransactionalEnforcer(m, adapter) if err != nil { diff --git a/internal/authorization/app/model_test.go b/internal/authorization/app/model_test.go index e560684ca..3cfbe2f5c 100644 --- a/internal/authorization/app/model_test.go +++ b/internal/authorization/app/model_test.go @@ -9,7 +9,7 @@ import ( // Shared at https://editor.casbin.org/#GURKL5ZXW // Runs the matrix against casbin's own CSV loading (string adapter) and against -// BootStrapPolicies + LoadFixtures syncing into a database, so both must agree. +// policy.Seed + policy.LoadFixtures syncing into a database, so both must agree. func TestModels(t *testing.T) { for _, useTransaction := range []bool{false, true} { t.Run(fmt.Sprintf("useTransaction=%t", useTransaction), func(t *testing.T) { diff --git a/internal/authorization/app/policysync.go b/internal/authorization/app/policysync.go deleted file mode 100644 index f93554a34..000000000 --- a/internal/authorization/app/policysync.go +++ /dev/null @@ -1,84 +0,0 @@ -package app - -import ( - "fmt" - "slices" - "strings" - - "github.com/casbin/casbin/v3" - "github.com/casbin/casbin/v3/model" - "github.com/casbin/casbin/v3/persist" -) - -func parsePolicyCSV(csv string) (model.Model, error) { - m, err := model.NewModelFromString(ModelConf) - if err != nil { - return nil, fmt.Errorf("failed to load Casbin model: %w", err) - } - - for line := range strings.SplitSeq(csv, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if err := persist.LoadPolicyLine(line, m); err != nil { - return nil, fmt.Errorf("failed to parse policy line %q: %w", line, err) - } - } - - return m, nil -} - -// syncPolicies adds every rule in csv to the enforcer. With prune, `p` rules -// missing from csv are removed. `g` rules are runtime memberships and never pruned. -func syncPolicies(e *casbin.TransactionalEnforcer, csv string, prune bool) error { - desired, err := parsePolicyCSV(csv) - if err != nil { - return err - } - - policies, err := desired.GetPolicy("p", "p") - if err != nil { - return fmt.Errorf("failed to read p rules: %w", err) - } - - if prune { - current, err := e.GetPolicy() - if err != nil { - return fmt.Errorf("failed to read current p rules: %w", err) - } - - var stale [][]string - for _, rule := range current { - if !slices.ContainsFunc(policies, func(r []string) bool { return slices.Equal(r, rule) }) { - stale = append(stale, rule) - } - } - if len(stale) > 0 { - if _, err := e.RemovePolicies(stale); err != nil { - return fmt.Errorf("failed to remove stale p rules: %w", err) - } - } - } - - if len(policies) > 0 { - if _, err := e.AddPoliciesEx(policies); err != nil { - return fmt.Errorf("failed to add p rules: %w", err) - } - } - - for _, ptype := range []string{"g", "g2"} { - rules, err := desired.GetPolicy("g", ptype) - if err != nil { - return fmt.Errorf("failed to read %s rules: %w", ptype, err) - } - if len(rules) == 0 { - continue - } - if _, err := e.AddNamedGroupingPoliciesEx(ptype, rules); err != nil { - return fmt.Errorf("failed to add %s rules: %w", ptype, err) - } - } - - return nil -} diff --git a/internal/authorization/controller/health/health.go b/internal/authorization/controller/health/health.go index d411fccbe..1e1368fea 100644 --- a/internal/authorization/controller/health/health.go +++ b/internal/authorization/controller/health/health.go @@ -9,7 +9,9 @@ import ( "time" "github.com/alexliesenfeld/health" + "github.com/casbin/casbin/v3" "github.com/notopia-uit/notopia/internal/authorization/config" + "github.com/notopia-uit/notopia/internal/authorization/policy" commonconfig "github.com/notopia-uit/notopia/pkg/common/config" "gorm.io/gorm" ) @@ -20,6 +22,7 @@ type Health struct { func New( db *gorm.DB, + enforcer *casbin.TransactionalEnforcer, serverCfg *config.ServerConfig, kafkaCfg *commonconfig.Kafka, ) *Health { @@ -39,6 +42,25 @@ func New( }, }, ), + health.WithCheck( + health.Check{ + Name: "policy", + Check: func(ctx context.Context) error { + drift, err := policy.DetectDrift(enforcer) + if err != nil { + return fmt.Errorf("failed to verify policy matrix: %w", err) + } + if !drift.IsEmpty() { + return fmt.Errorf( + "policy matrix drifted (%d missing, %d unexpected rules), seed required", + len(drift.Missing), + len(drift.Unexpected), + ) + } + return nil + }, + }, + ), ) readyChecker := health.NewChecker( diff --git a/internal/authorization/policy/embed.go b/internal/authorization/policy/embed.go new file mode 100644 index 000000000..5833eacff --- /dev/null +++ b/internal/authorization/policy/embed.go @@ -0,0 +1,12 @@ +package policy + +import _ "embed" + +//go:embed model.conf +var ModelConf string + +//go:embed policy.csv +var CSV string + +//go:embed policy_fixtures.csv +var FixturesCSV string diff --git a/internal/authorization/app/model.conf b/internal/authorization/policy/model.conf similarity index 100% rename from internal/authorization/app/model.conf rename to internal/authorization/policy/model.conf diff --git a/internal/authorization/app/policy.csv b/internal/authorization/policy/policy.csv similarity index 100% rename from internal/authorization/app/policy.csv rename to internal/authorization/policy/policy.csv diff --git a/internal/authorization/policy/policy.go b/internal/authorization/policy/policy.go new file mode 100644 index 000000000..519918808 --- /dev/null +++ b/internal/authorization/policy/policy.go @@ -0,0 +1,155 @@ +package policy + +import ( + "fmt" + "slices" + "strings" + + "github.com/casbin/casbin/v3" + "github.com/casbin/casbin/v3/model" + "github.com/casbin/casbin/v3/persist" +) + +func parsePolicyCSV(csv string) (model.Model, error) { + m, err := model.NewModelFromString(ModelConf) + if err != nil { + return nil, fmt.Errorf("failed to load Casbin model: %w", err) + } + + for line := range strings.SplitSeq(csv, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if err := persist.LoadPolicyLine(line, m); err != nil { + return nil, fmt.Errorf("failed to parse policy line %q: %w", line, err) + } + } + + return m, nil +} + +// syncRules adds every rule in csv to the enforcer. With prune, `p` rules +// missing from csv are removed. `g` rules are runtime memberships and never pruned. +func syncRules(e *casbin.TransactionalEnforcer, csv string, prune bool) error { + desired, err := parsePolicyCSV(csv) + if err != nil { + return err + } + + policies, err := desired.GetPolicy("p", "p") + if err != nil { + return fmt.Errorf("failed to read p rules: %w", err) + } + + if prune { + current, err := e.GetPolicy() + if err != nil { + return fmt.Errorf("failed to read current p rules: %w", err) + } + + stale := rulesNotIn(current, policies) + if len(stale) > 0 { + if _, err := e.RemovePolicies(stale); err != nil { + return fmt.Errorf("failed to remove stale p rules: %w", err) + } + } + } + + if len(policies) > 0 { + if _, err := e.AddPoliciesEx(policies); err != nil { + return fmt.Errorf("failed to add p rules: %w", err) + } + } + + for _, ptype := range []string{"g", "g2"} { + rules, err := desired.GetPolicy("g", ptype) + if err != nil { + return fmt.Errorf("failed to read %s rules: %w", ptype, err) + } + if len(rules) == 0 { + continue + } + if _, err := e.AddNamedGroupingPoliciesEx(ptype, rules); err != nil { + return fmt.Errorf("failed to add %s rules: %w", ptype, err) + } + } + + return nil +} + +func Seed(e *casbin.TransactionalEnforcer) error { + if err := syncRules(e, CSV, true); err != nil { + return fmt.Errorf("failed to seed policies: %w", err) + } + return nil +} + +func LoadFixtures(e *casbin.TransactionalEnforcer) error { + if err := syncRules(e, FixturesCSV, false); err != nil { + return fmt.Errorf("failed to load fixture policies: %w", err) + } + return nil +} + +func rulesNotIn(rules, reference [][]string) [][]string { + var result [][]string + for _, rule := range rules { + if !slices.ContainsFunc(reference, func(r []string) bool { return slices.Equal(r, rule) }) { + result = append(result, rule) + } + } + return result +} + +// Drift is the difference between the embedded policy matrix and the enforcer. +type Drift struct { + Missing [][]string + Unexpected [][]string +} + +func (d Drift) IsEmpty() bool { + return len(d.Missing) == 0 && len(d.Unexpected) == 0 +} + +// DetectDrift checks the enforcer's in-memory policy, which is loaded once at +// startup, so a seed applied afterwards is only picked up by a restart. +// Only `p` rules can be unexpected, `g` rules are runtime memberships. +func DetectDrift(e *casbin.TransactionalEnforcer) (Drift, error) { + desired, err := parsePolicyCSV(CSV) + if err != nil { + return Drift{}, err + } + + var drift Drift + + policies, err := desired.GetPolicy("p", "p") + if err != nil { + return Drift{}, fmt.Errorf("failed to read p rules: %w", err) + } + + current, err := e.GetPolicy() + if err != nil { + return Drift{}, fmt.Errorf("failed to read current p rules: %w", err) + } + for _, rule := range rulesNotIn(policies, current) { + drift.Missing = append(drift.Missing, append([]string{"p"}, rule...)) + } + drift.Unexpected = rulesNotIn(current, policies) + + rules, err := desired.GetPolicy("g", "g2") + if err != nil { + return Drift{}, fmt.Errorf("failed to read g2 rules: %w", err) + } + for _, rule := range rules { + ok, err := e.HasNamedGroupingPolicy("g2", rule) + if err != nil { + return Drift{}, fmt.Errorf("failed to check g2 rule: %w", err) + } + if !ok { + drift.Missing = append(drift.Missing, append([]string{"g2"}, rule...)) + } + } + + return drift, nil +} diff --git a/internal/authorization/app/policy_fixtures.csv b/internal/authorization/policy/policy_fixtures.csv similarity index 100% rename from internal/authorization/app/policy_fixtures.csv rename to internal/authorization/policy/policy_fixtures.csv diff --git a/internal/authorization/app/policysync_internal_test.go b/internal/authorization/policy/policy_internal_test.go similarity index 92% rename from internal/authorization/app/policysync_internal_test.go rename to internal/authorization/policy/policy_internal_test.go index ab9d21f12..b3f9eee61 100644 --- a/internal/authorization/app/policysync_internal_test.go +++ b/internal/authorization/policy/policy_internal_test.go @@ -1,4 +1,4 @@ -package app +package policy import ( "testing" @@ -26,7 +26,7 @@ func TestParsePolicyCSV(t *testing.T) { } func TestParsePolicyCSVEmbeddedFiles(t *testing.T) { - m, err := parsePolicyCSV(PolicyCSV) + m, err := parsePolicyCSV(CSV) require.NoError(t, err) p, err := m.GetPolicy("p", "p") require.NoError(t, err) @@ -35,7 +35,7 @@ func TestParsePolicyCSVEmbeddedFiles(t *testing.T) { require.NoError(t, err) assert.Len(t, g2, 2) - _, err = parsePolicyCSV(PolicyFixturesCSV) + _, err = parsePolicyCSV(FixturesCSV) require.NoError(t, err) } diff --git a/internal/authorization/app/bootstrap_test.go b/internal/authorization/policy/seed_test.go similarity index 65% rename from internal/authorization/app/bootstrap_test.go rename to internal/authorization/policy/seed_test.go index 0faa76b11..c8ae8fa1a 100644 --- a/internal/authorization/app/bootstrap_test.go +++ b/internal/authorization/policy/seed_test.go @@ -1,7 +1,6 @@ -package app_test +package policy_test import ( - "context" "path/filepath" "testing" @@ -9,30 +8,30 @@ import ( "github.com/casbin/casbin/v3/model" gormadapter "github.com/casbin/gorm-adapter/v3" "github.com/glebarez/sqlite" - "github.com/notopia-uit/notopia/internal/authorization/app" + "github.com/notopia-uit/notopia/internal/authorization/policy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" ) -func TestBootStrapPolicies_AddsMissingRowsOnExistingDatabase(t *testing.T) { - enforcer, err := GetLocalEnforcer(t, &GetLocalEnforcerParams{UseTransaction: true}) - require.NoError(t, err) +func TestSeed_AddsMissingRowsOnExistingDatabase(t *testing.T) { + enforcer := newPersistentEnforcer(t, filepath.Join(t.TempDir(), "casbin.db")) + require.NoError(t, policy.Seed(enforcer)) - _, err = enforcer.RemovePolicies([][]string{ + removed, err := enforcer.RemovePolicies([][]string{ {"owner", "workspace", "billing_read"}, {"owner", "workspace", "billing_manage"}, {"editor", "workspace", "billing_read"}, {"viewer", "workspace", "billing_read"}, }) require.NoError(t, err) + require.True(t, removed) hasBilling, err := enforcer.HasPolicy("owner", "workspace", "billing_read") require.NoError(t, err) require.False(t, hasBilling) - a := &app.App{Enforcer: enforcer} - require.NoError(t, a.BootStrapPolicies(context.Background())) + require.NoError(t, policy.Seed(enforcer)) for _, rule := range [][]string{ {"owner", "workspace", "read"}, @@ -46,7 +45,7 @@ func TestBootStrapPolicies_AddsMissingRowsOnExistingDatabase(t *testing.T) { assert.True(t, ok, rule) } - require.NoError(t, a.BootStrapPolicies(context.Background()), "bootstrap must be idempotent") + require.NoError(t, policy.Seed(enforcer), "seed must be idempotent") require.NoError(t, enforcer.LoadPolicy()) ok, err := enforcer.HasPolicy("viewer", "workspace", "billing_read") @@ -57,7 +56,7 @@ func TestBootStrapPolicies_AddsMissingRowsOnExistingDatabase(t *testing.T) { func newPersistentEnforcer(t *testing.T, path string) *casbin.TransactionalEnforcer { t.Helper() - m, err := model.NewModelFromString(app.ModelConf) + m, err := model.NewModelFromString(policy.ModelConf) require.NoError(t, err) db, err := gorm.Open(sqlite.Open(path), &gorm.Config{}) @@ -76,26 +75,24 @@ func newPersistentEnforcer(t *testing.T, path string) *casbin.TransactionalEnfor return enforcer } -func TestBootStrapPolicies_KeepsPersistedMembershipsAcrossRestart(t *testing.T) { +func TestSeed_KeepsPersistedMembershipsAcrossRestart(t *testing.T) { const workspace = "workspace:aaaaaaaa-0000-4000-8000-000000000001" path := filepath.Join(t.TempDir(), "casbin.db") - ctx := context.Background() first := newPersistentEnforcer(t, path) - require.NoError(t, (&app.App{Enforcer: first}).BootStrapPolicies(ctx)) + require.NoError(t, policy.Seed(first)) added, err := first.AddGroupingPolicy("user:999", "owner", workspace) require.NoError(t, err) require.True(t, added) restarted := newPersistentEnforcer(t, path) - a := &app.App{Enforcer: restarted} - require.NoError(t, a.BootStrapPolicies(ctx)) + require.NoError(t, policy.Seed(restarted)) ok, err := restarted.Enforce("user:999", workspace, "workspace", "read") require.NoError(t, err) - assert.True(t, ok, "persisted membership must survive bootstrap") + assert.True(t, ok, "persisted membership must survive seed") - require.NoError(t, a.LoadFixtures(ctx)) + require.NoError(t, policy.LoadFixtures(restarted)) ok, err = restarted.Enforce("user:999", workspace, "workspace", "read") require.NoError(t, err) @@ -111,16 +108,13 @@ func TestBootStrapPolicies_KeepsPersistedMembershipsAcrossRestart(t *testing.T) assert.True(t, ok, "fixture membership must be effective") } -func TestBootStrapPolicies_PrunesStalePolicies(t *testing.T) { - enforcer, err := GetLocalEnforcer(t, &GetLocalEnforcerParams{UseTransaction: true}) - require.NoError(t, err) - - _, err = enforcer.AddPolicy("viewer", "workspace", "delete") +func TestSeed_PrunesStalePolicies(t *testing.T) { + enforcer := newPersistentEnforcer(t, filepath.Join(t.TempDir(), "casbin.db")) + _, err := enforcer.AddPolicy("viewer", "workspace", "delete") require.NoError(t, err) - a := &app.App{Enforcer: enforcer} - require.NoError(t, a.BootStrapPolicies(context.Background())) - require.NoError(t, a.BootStrapPolicies(context.Background())) + require.NoError(t, policy.Seed(enforcer)) + require.NoError(t, policy.Seed(enforcer)) stale, err := enforcer.HasPolicy("viewer", "workspace", "delete") require.NoError(t, err) @@ -135,3 +129,25 @@ func TestBootStrapPolicies_PrunesStalePolicies(t *testing.T) { require.NoError(t, err) assert.False(t, stale, "prune must be persisted") } + +func TestDetectDrift(t *testing.T) { + enforcer := newPersistentEnforcer(t, filepath.Join(t.TempDir(), "casbin.db")) + + drift, err := policy.DetectDrift(enforcer) + require.NoError(t, err) + assert.NotEmpty(t, drift.Missing) + + require.NoError(t, policy.Seed(enforcer)) + + drift, err = policy.DetectDrift(enforcer) + require.NoError(t, err) + assert.True(t, drift.IsEmpty()) + + _, err = enforcer.AddPolicy("viewer", "workspace", "delete") + require.NoError(t, err) + + drift, err = policy.DetectDrift(enforcer) + require.NoError(t, err) + assert.Empty(t, drift.Missing) + assert.Equal(t, [][]string{{"viewer", "workspace", "delete"}}, drift.Unexpected) +} diff --git a/internal/authorization/project.json b/internal/authorization/project.json index 5787bb47f..084cb783d 100644 --- a/internal/authorization/project.json +++ b/internal/authorization/project.json @@ -4,7 +4,7 @@ "projectType": "library", "tags": ["scope:library", "type:go"], "namedInputs": { - "default": ["{projectRoot}/**/*.go", "{projectRoot}/app/*.conf", "{projectRoot}/app/*.csv", "!{projectRoot}/_test.go"] + "default": ["{projectRoot}/**/*.go", "{projectRoot}/policy/*.conf", "{projectRoot}/policy/*.csv", "!{projectRoot}/_test.go"] }, "targets": { "gen:mockery": { diff --git a/internal/authorization/server.go b/internal/authorization/server.go index 06f17b670..11b8dcdf9 100644 --- a/internal/authorization/server.go +++ b/internal/authorization/server.go @@ -9,6 +9,7 @@ import ( "github.com/notopia-uit/notopia/internal/authorization/app" "github.com/notopia-uit/notopia/internal/authorization/controller/grpc" "github.com/notopia-uit/notopia/internal/authorization/controller/health" + "github.com/notopia-uit/notopia/internal/authorization/policy" commonconfig "github.com/notopia-uit/notopia/pkg/common/config" "github.com/notopia-uit/notopia/pkg/otel" "golang.org/x/sync/errgroup" @@ -33,13 +34,21 @@ func NewServer( ) (*Server, error) { slog.SetDefault(logger) - if err := authApp.BootStrapPolicies(ctx); err != nil { - return nil, fmt.Errorf("failed to bootstrap policies: %w", err) - } - if generalCfg.AppEnv == commonconfig.AppEnvDevelopment { - if err := authApp.LoadFixtures(ctx); err != nil { - return nil, fmt.Errorf("failed to load dev fixture policies: %w", err) - } + drift, err := policy.DetectDrift(authApp.Enforcer) + switch { + case err != nil: + logger.ErrorContext( + ctx, + "failed to verify authorization policy matrix, the service must be seeded before start", + slog.Any("error", err), + ) + case !drift.IsEmpty(): + logger.ErrorContext( + ctx, + "authorization policy matrix is missing or outdated, the service must be seeded before start", + slog.Int("missing_rules", len(drift.Missing)), + slog.Int("unexpected_rules", len(drift.Unexpected)), + ) } return &Server{ diff --git a/internal/authorizationfixtures/config.go b/internal/authorizationfixtures/config.go new file mode 100644 index 000000000..11861a68b --- /dev/null +++ b/internal/authorizationfixtures/config.go @@ -0,0 +1,60 @@ +package authorizationfixtures + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/creasty/defaults" + "github.com/go-playground/validator/v10" + commonconfig "github.com/notopia-uit/notopia/pkg/common/config" + "github.com/spf13/viper" +) + +type Config struct { + General commonconfig.General `json:"general" mapstructure:"general" validate:"omitempty" yaml:"general"` + Log commonconfig.Log `json:"log" mapstructure:"log" validate:"omitempty" yaml:"log"` + Database commonconfig.SQL `json:"database" mapstructure:"database" validate:"required" yaml:"database"` +} + +func NewConfig(validate *validator.Validate, viper *viper.Viper) (*Config, error) { + viper.SetEnvPrefix("notopia_authorizationfixtures") + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.SetConfigName("authorizationfixtures.notopia.config") + viper.AddConfigPath(".") + viper.AutomaticEnv() + if err := viper.ReadInConfig(); err == nil { + slog.Info("configuration loaded", slog.String("file", viper.ConfigFileUsed())) + } + + cfg := &Config{} + if err := defaults.Set(cfg); err != nil { + return nil, fmt.Errorf("cannot set default config: %w", err) + } + if err := viper.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("cannot unmarshal config from env or config file: %w", err) + } + slog.Info("configuration", slog.Any("config", cfg)) + if err := validate.Struct(cfg); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + return cfg, nil +} + +var ProvideConfig = NewConfig + +func NewViper() *viper.Viper { + return viper.NewWithOptions( + viper.ExperimentalBindStruct(), + ) +} + +var ProvideViper = NewViper + +func NewValidate() *validator.Validate { + return validator.New( + validator.WithRequiredStructEnabled(), + ) +} + +var ProvideValidate = NewValidate diff --git a/internal/authorizationfixtures/fixtures.go b/internal/authorizationfixtures/fixtures.go new file mode 100644 index 000000000..854c1ac3e --- /dev/null +++ b/internal/authorizationfixtures/fixtures.go @@ -0,0 +1,32 @@ +package authorizationfixtures + +import ( + "context" + "log/slog" + + "github.com/casbin/casbin/v3" + "github.com/notopia-uit/notopia/internal/authorization/policy" +) + +type Fixtures struct { + enforcer *casbin.TransactionalEnforcer + logger *slog.Logger +} + +func NewFixtures(enforcer *casbin.TransactionalEnforcer, logger *slog.Logger) *Fixtures { + return &Fixtures{ + enforcer: enforcer, + logger: logger, + } +} + +var ProvideFixtures = NewFixtures + +// Run is add-only and assumes the policy matrix was already seeded. +func (f *Fixtures) Run(ctx context.Context) error { + if err := policy.LoadFixtures(f.enforcer); err != nil { + return err + } + f.logger.InfoContext(ctx, "authorization fixture policies loaded") + return nil +} diff --git a/internal/authorizationfixtures/wire.go b/internal/authorizationfixtures/wire.go new file mode 100644 index 000000000..3a3683013 --- /dev/null +++ b/internal/authorizationfixtures/wire.go @@ -0,0 +1,26 @@ +package authorizationfixtures + +import ( + "github.com/goforj/wire" + "github.com/notopia-uit/notopia/internal/authorization/app" + "github.com/notopia-uit/notopia/internal/authorization/infra" + "github.com/notopia-uit/notopia/pkg/logging" + "github.com/notopia-uit/notopia/pkg/otel" +) + +var ProviderSet = wire.NewSet( + ProvideFixtures, + ProvideValidate, + ProvideViper, + ProvideConfig, + wire.FieldsOf( + new(*Config), + "Database", + "General", + "Log", + ), + infra.ProviderSetCasbin, + app.ProvideCasbinEnforcer, + logging.ProviderSet, + otel.ProviderSet, +) diff --git a/internal/authorizationseed/config.go b/internal/authorizationseed/config.go new file mode 100644 index 000000000..7a0515f01 --- /dev/null +++ b/internal/authorizationseed/config.go @@ -0,0 +1,60 @@ +package authorizationseed + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/creasty/defaults" + "github.com/go-playground/validator/v10" + commonconfig "github.com/notopia-uit/notopia/pkg/common/config" + "github.com/spf13/viper" +) + +type Config struct { + General commonconfig.General `json:"general" mapstructure:"general" validate:"omitempty" yaml:"general"` + Log commonconfig.Log `json:"log" mapstructure:"log" validate:"omitempty" yaml:"log"` + Database commonconfig.SQL `json:"database" mapstructure:"database" validate:"required" yaml:"database"` +} + +func NewConfig(validate *validator.Validate, viper *viper.Viper) (*Config, error) { + viper.SetEnvPrefix("notopia_authorizationseed") + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + viper.SetConfigName("authorizationseed.notopia.config") + viper.AddConfigPath(".") + viper.AutomaticEnv() + if err := viper.ReadInConfig(); err == nil { + slog.Info("configuration loaded", slog.String("file", viper.ConfigFileUsed())) + } + + cfg := &Config{} + if err := defaults.Set(cfg); err != nil { + return nil, fmt.Errorf("cannot set default config: %w", err) + } + if err := viper.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("cannot unmarshal config from env or config file: %w", err) + } + slog.Info("configuration", slog.Any("config", cfg)) + if err := validate.Struct(cfg); err != nil { + return nil, fmt.Errorf("config validation failed: %w", err) + } + return cfg, nil +} + +var ProvideConfig = NewConfig + +func NewViper() *viper.Viper { + return viper.NewWithOptions( + viper.ExperimentalBindStruct(), + ) +} + +var ProvideViper = NewViper + +func NewValidate() *validator.Validate { + return validator.New( + validator.WithRequiredStructEnabled(), + ) +} + +var ProvideValidate = NewValidate diff --git a/internal/authorizationseed/seed.go b/internal/authorizationseed/seed.go new file mode 100644 index 000000000..a8be7b30a --- /dev/null +++ b/internal/authorizationseed/seed.go @@ -0,0 +1,32 @@ +package authorizationseed + +import ( + "context" + "log/slog" + + "github.com/casbin/casbin/v3" + "github.com/notopia-uit/notopia/internal/authorization/policy" +) + +type Seeder struct { + enforcer *casbin.TransactionalEnforcer + logger *slog.Logger +} + +func NewSeeder(enforcer *casbin.TransactionalEnforcer, logger *slog.Logger) *Seeder { + return &Seeder{ + enforcer: enforcer, + logger: logger, + } +} + +var ProvideSeeder = NewSeeder + +// Run is idempotent: missing rules are added and stale p rules are pruned. +func (s *Seeder) Run(ctx context.Context) error { + if err := policy.Seed(s.enforcer); err != nil { + return err + } + s.logger.InfoContext(ctx, "authorization policy matrix seeded") + return nil +} diff --git a/internal/authorizationseed/wire.go b/internal/authorizationseed/wire.go new file mode 100644 index 000000000..a94590c2d --- /dev/null +++ b/internal/authorizationseed/wire.go @@ -0,0 +1,26 @@ +package authorizationseed + +import ( + "github.com/goforj/wire" + "github.com/notopia-uit/notopia/internal/authorization/app" + "github.com/notopia-uit/notopia/internal/authorization/infra" + "github.com/notopia-uit/notopia/pkg/logging" + "github.com/notopia-uit/notopia/pkg/otel" +) + +var ProviderSet = wire.NewSet( + ProvideSeeder, + ProvideValidate, + ProvideViper, + ProvideConfig, + wire.FieldsOf( + new(*Config), + "Database", + "General", + "Log", + ), + infra.ProviderSetCasbin, + app.ProvideCasbinEnforcer, + logging.ProviderSet, + otel.ProviderSet, +) diff --git a/internal/billingfixtures/config/config.go b/internal/billingfixtures/config.go similarity index 92% rename from internal/billingfixtures/config/config.go rename to internal/billingfixtures/config.go index 3800c7354..3c9a847d3 100644 --- a/internal/billingfixtures/config/config.go +++ b/internal/billingfixtures/config.go @@ -1,4 +1,4 @@ -package config +package billingfixtures import ( "fmt" @@ -17,7 +17,7 @@ type Config struct { Database commonconfig.SQL `json:"database" mapstructure:"database" validate:"required" yaml:"database"` } -func New(validate *validator.Validate, viper *viper.Viper) (*Config, error) { +func NewConfig(validate *validator.Validate, viper *viper.Viper) (*Config, error) { viper.SetEnvPrefix("notopia_billingfixtures") viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.SetConfigName("billingfixtures.notopia.config") @@ -41,7 +41,7 @@ func New(validate *validator.Validate, viper *viper.Viper) (*Config, error) { return cfg, nil } -var Provide = New +var ProvideConfig = NewConfig func NewViper() *viper.Viper { return viper.NewWithOptions( diff --git a/internal/billingfixtures/config/wire.go b/internal/billingfixtures/config/wire.go deleted file mode 100644 index 6ad5febdd..000000000 --- a/internal/billingfixtures/config/wire.go +++ /dev/null @@ -1,15 +0,0 @@ -package config - -import "github.com/goforj/wire" - -var ProviderSet = wire.NewSet( - ProvideValidate, - ProvideViper, - Provide, - wire.FieldsOf( - new(*Config), - "Database", - "General", - "Log", - ), -) diff --git a/internal/billingfixtures/wire.go b/internal/billingfixtures/wire.go index d31aebba4..3fed86855 100644 --- a/internal/billingfixtures/wire.go +++ b/internal/billingfixtures/wire.go @@ -3,14 +3,21 @@ package billingfixtures import ( "github.com/goforj/wire" "github.com/notopia-uit/notopia/internal/billing/infra/persistence" - "github.com/notopia-uit/notopia/internal/billingfixtures/config" "github.com/notopia-uit/notopia/pkg/logging" "github.com/notopia-uit/notopia/pkg/otel" ) var ProviderSet = wire.NewSet( ProvideFixtures, - config.ProviderSet, + ProvideValidate, + ProvideViper, + ProvideConfig, + wire.FieldsOf( + new(*Config), + "Database", + "General", + "Log", + ), persistence.ProviderSet, logging.ProviderSet, otel.ProviderSet, diff --git a/internal/billingseed/config/config.go b/internal/billingseed/config.go similarity index 94% rename from internal/billingseed/config/config.go rename to internal/billingseed/config.go index 5ccb9b338..13f159cea 100644 --- a/internal/billingseed/config/config.go +++ b/internal/billingseed/config.go @@ -1,4 +1,4 @@ -package config +package billingseed import ( "fmt" @@ -29,7 +29,7 @@ type Config struct { Identity commonconfig.Identity `json:"identity" mapstructure:"identity" validate:"required" yaml:"identity"` } -func New(validate *validator.Validate, viper *viper.Viper) (*Config, error) { +func NewConfig(validate *validator.Validate, viper *viper.Viper) (*Config, error) { viper.SetEnvPrefix("notopia_billingseed") viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.SetConfigName("billingseed.notopia.config") @@ -53,7 +53,7 @@ func New(validate *validator.Validate, viper *viper.Viper) (*Config, error) { return cfg, nil } -var Provide = New +var ProvideConfig = NewConfig func NewViper() *viper.Viper { return viper.NewWithOptions( diff --git a/internal/billingseed/config/wire.go b/internal/billingseed/config/wire.go deleted file mode 100644 index afb98dd12..000000000 --- a/internal/billingseed/config/wire.go +++ /dev/null @@ -1,18 +0,0 @@ -package config - -import "github.com/goforj/wire" - -var ProviderSet = wire.NewSet( - ProvideValidate, - ProvideViper, - Provide, - wire.FieldsOf( - new(*Config), - "Catalog", - "Database", - "General", - "Identity", - "Log", - "Stripe", - ), -) diff --git a/internal/billingseed/seed.go b/internal/billingseed/seed.go index ec5961ccc..92411621b 100644 --- a/internal/billingseed/seed.go +++ b/internal/billingseed/seed.go @@ -7,7 +7,6 @@ import ( "github.com/google/uuid" "github.com/notopia-uit/notopia/internal/billing/app" "github.com/notopia-uit/notopia/internal/billing/infra/persistence" - "github.com/notopia-uit/notopia/internal/billingseed/config" commonconfig "github.com/notopia-uit/notopia/pkg/common/config" ) @@ -31,7 +30,7 @@ type Seeder struct { listPrices *app.ListPricesHandler createPrice *app.CreatePriceHandler ensurePortal *app.EnsurePortalConfigurationHandler - catalog *config.Catalog + catalog *Catalog identity *commonconfig.Identity logger *slog.Logger } @@ -42,7 +41,7 @@ func NewSeeder( listPrices *app.ListPricesHandler, createPrice *app.CreatePriceHandler, ensurePortal *app.EnsurePortalConfigurationHandler, - catalog *config.Catalog, + catalog *Catalog, identity *commonconfig.Identity, logger *slog.Logger, ) *Seeder { diff --git a/internal/billingseed/wire.go b/internal/billingseed/wire.go index 3bb95889c..8735b9d1d 100644 --- a/internal/billingseed/wire.go +++ b/internal/billingseed/wire.go @@ -6,16 +6,15 @@ import ( billingconfig "github.com/notopia-uit/notopia/internal/billing/config" stripeadapter "github.com/notopia-uit/notopia/internal/billing/infra/payment/stripe" "github.com/notopia-uit/notopia/internal/billing/infra/persistence" - "github.com/notopia-uit/notopia/internal/billingseed/config" commonconfig "github.com/notopia-uit/notopia/pkg/common/config" "github.com/notopia-uit/notopia/pkg/logging" "github.com/notopia-uit/notopia/pkg/otel" ) // Billing infra constructors take billing's config types; the small seed config is mapped onto -// them here, so neither config package imports the other or app. +// them here, so the seed config does not import billing config or app. -func NewBillingStripeConfig(stripe *config.Stripe) *billingconfig.Stripe { +func NewBillingStripeConfig(stripe *Stripe) *billingconfig.Stripe { return &billingconfig.Stripe{ SecretKey: stripe.SecretKey, WebhookSecret: "", @@ -24,7 +23,7 @@ func NewBillingStripeConfig(stripe *config.Stripe) *billingconfig.Stripe { var ProvideBillingStripeConfig = NewBillingStripeConfig -func NewBillingConfig(catalog *config.Catalog) *billingconfig.Billing { +func NewBillingConfig(catalog *Catalog) *billingconfig.Billing { return &billingconfig.Billing{ Currency: catalog.Currency, WebBaseURL: catalog.WebBaseURL, @@ -33,7 +32,7 @@ func NewBillingConfig(catalog *config.Catalog) *billingconfig.Billing { var ProvideBillingConfig = NewBillingConfig -func NewAppSettings(catalog *config.Catalog, identity *commonconfig.Identity) *app.Settings { +func NewAppSettings(catalog *Catalog, identity *commonconfig.Identity) *app.Settings { return &app.Settings{ WebBaseURL: catalog.WebBaseURL, ServiceAccountUserID: identity.ServiceAccountUserID, @@ -47,7 +46,18 @@ var ProviderSet = wire.NewSet( ProvideBillingStripeConfig, ProvideBillingConfig, ProvideAppSettings, - config.ProviderSet, + ProvideValidate, + ProvideViper, + ProvideConfig, + wire.FieldsOf( + new(*Config), + "Catalog", + "Database", + "General", + "Identity", + "Log", + "Stripe", + ), persistence.ProviderSet, stripeadapter.Provide, wire.Bind(new(app.PaymentProvider), new(*stripeadapter.Provider)), diff --git a/justfile b/justfile index cb493de13..53f8fd69d 100644 --- a/justfile +++ b/justfile @@ -34,7 +34,10 @@ export-authentik-blueprint: shadcnadd component: pnpm exec shadcn add {{ component }} -c ./packages/ui/ -seed: seed-search seed-billing +seed: seed-authorization seed-search seed-billing + +seed-authorization: + nx run authorizationseed:run seed-search: nx run search-worker:seed @@ -42,7 +45,7 @@ seed-search: seed-billing: nx run billingseed:run -fixtures: seed fixtures-note fixtures-document fixtures-search fixtures-billing +fixtures: seed fixtures-authorization fixtures-note fixtures-document fixtures-search fixtures-billing fixtures-note db-connection=note-db-connection: prepare-fixtures-note psql {{ db-connection }} \ @@ -68,6 +71,9 @@ prepare-fixtures-search: fixtures-billing: nx run billingfixtures:run +fixtures-authorization: + nx run authorizationfixtures:run + stripe-listen: stripe listen \ --all-snapshot \