- Add pact.TokenScope/HasTokenScopes and bouncer.ScopeLimit on Principal - Append backend_api_tokens migration; Allows evaluates scope before superuser - Composite guard routes Bearer g15_… to token lookup; smoke tests cover path Co-authored-by: Cursor <cursoragent@cursor.com>
166 lines
6.0 KiB
Go
166 lines
6.0 KiB
Go
package lagoon
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/go-gormigrate/gormigrate/v2"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// BackendJWTBlacklistTable is the framework-owned jti table for admin tokens.
|
|
// It is distinct from the frontend jwt_blacklist table.
|
|
const BackendJWTBlacklistTable = "backend_jwt_blacklist"
|
|
|
|
// BackendAdminMigrations creates Winter-shaped backend identity tables, seeds
|
|
// the developer and publisher system roles, makes backend user emails
|
|
// unique case-insensitively, adds WinterCMS's nullable jsonable metadata
|
|
// column to backend_users (the admin subtitle lives there), and creates
|
|
// backend_api_tokens for personal admin API tokens. History is isolated under
|
|
// the summercms.cabana plugin id. DDL is re-runnable so the system-role seed
|
|
// stays idempotent if the history row is removed.
|
|
var BackendAdminMigrations = []*gormigrate.Migration{
|
|
{
|
|
ID: "202609240001_backend_admin_identity",
|
|
Migrate: func(tx *gorm.DB) error {
|
|
stmts := []string{
|
|
`CREATE TABLE IF NOT EXISTS backend_user_roles (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
code TEXT,
|
|
description TEXT,
|
|
permissions TEXT,
|
|
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS backend_users (
|
|
id SERIAL PRIMARY KEY,
|
|
first_name TEXT,
|
|
last_name TEXT,
|
|
login TEXT NOT NULL UNIQUE,
|
|
email TEXT NOT NULL UNIQUE,
|
|
password TEXT NOT NULL,
|
|
activation_code TEXT,
|
|
persist_code TEXT,
|
|
reset_password_code TEXT,
|
|
permissions TEXT,
|
|
is_activated BOOLEAN NOT NULL DEFAULT FALSE,
|
|
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
|
|
role_id INTEGER REFERENCES backend_user_roles(id),
|
|
activated_at TIMESTAMPTZ,
|
|
last_login TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
deleted_at TIMESTAMPTZ,
|
|
tokens_valid_after TIMESTAMPTZ
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS backend_jwt_blacklist (
|
|
jti TEXT PRIMARY KEY,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
valid_until TIMESTAMPTZ NOT NULL
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS backend_users_role_id_index ON backend_users (role_id)`,
|
|
`CREATE INDEX IF NOT EXISTS backend_users_deleted_at_index ON backend_users (deleted_at)`,
|
|
`CREATE INDEX IF NOT EXISTS backend_users_activation_code_index ON backend_users (activation_code)`,
|
|
`CREATE INDEX IF NOT EXISTS backend_users_reset_password_code_index ON backend_users (reset_password_code)`,
|
|
`CREATE INDEX IF NOT EXISTS backend_user_roles_code_index ON backend_user_roles (code)`,
|
|
`INSERT INTO backend_user_roles (name, code, description, permissions, is_system)
|
|
VALUES
|
|
('Developer', 'developer', 'Site administrator with access to developer tools.', '{}', TRUE),
|
|
('Publisher', 'publisher', 'Site editor with access to publishing tools.', '{}', TRUE)
|
|
ON CONFLICT (name) DO NOTHING`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if err := tx.Exec(stmt).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
Rollback: func(tx *gorm.DB) error {
|
|
for _, stmt := range []string{
|
|
`DROP TABLE IF EXISTS backend_jwt_blacklist`,
|
|
`DROP TABLE IF EXISTS backend_users`,
|
|
`DROP TABLE IF EXISTS backend_user_roles`,
|
|
} {
|
|
if err := tx.Exec(stmt).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
{
|
|
// Emails are matched case-insensitively at login and by admin:create,
|
|
// so the table enforces the same rule. Rows copied from Winter may
|
|
// hold two emails that differ only in case; the migration refuses to
|
|
// run and names them instead of picking an account to drop.
|
|
ID: "202610010001_backend_users_email_ci_unique",
|
|
Migrate: func(tx *gorm.DB) error {
|
|
var dupes []struct {
|
|
Email string
|
|
Logins string
|
|
}
|
|
if err := tx.Raw(`SELECT lower(email) AS email, string_agg(login, ', ' ORDER BY id) AS logins
|
|
FROM backend_users GROUP BY lower(email) HAVING count(*) > 1 ORDER BY 1`).Scan(&dupes).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(dupes) > 0 {
|
|
parts := make([]string, len(dupes))
|
|
for i, d := range dupes {
|
|
parts[i] = fmt.Sprintf("%s (logins: %s)", d.Email, d.Logins)
|
|
}
|
|
return fmt.Errorf("lagoon: backend_users has emails that differ only in case: %s; change or remove the duplicates, then run migrate again", strings.Join(parts, "; "))
|
|
}
|
|
return tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS backend_users_email_lower_unique ON backend_users (lower(email))`).Error
|
|
},
|
|
Rollback: func(tx *gorm.DB) error {
|
|
return tx.Exec(`DROP INDEX IF EXISTS backend_users_email_lower_unique`).Error
|
|
},
|
|
},
|
|
{
|
|
// WinterCMS 2023_02_16_000012_Db_Backend_Add_User_Metadata: a
|
|
// nullable jsonable column. It is TEXT, not JSONB, so a copied value
|
|
// such as [] or a blank string loads unchanged.
|
|
ID: "202610090001_backend_users_metadata",
|
|
Migrate: func(tx *gorm.DB) error {
|
|
return tx.Exec(`ALTER TABLE backend_users ADD COLUMN IF NOT EXISTS metadata TEXT`).Error
|
|
},
|
|
Rollback: func(tx *gorm.DB) error {
|
|
return tx.Exec(`ALTER TABLE backend_users DROP COLUMN IF EXISTS metadata`).Error
|
|
},
|
|
},
|
|
{
|
|
// Personal admin API tokens (sha256 of g15_… only; plaintext never stored).
|
|
ID: "202610110001_backend_api_tokens",
|
|
Migrate: func(tx *gorm.DB) error {
|
|
for _, stmt := range []string{
|
|
`CREATE TABLE IF NOT EXISTS backend_api_tokens (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
backend_user_id INTEGER NOT NULL REFERENCES backend_users(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
token_hash CHAR(64) NOT NULL UNIQUE,
|
|
token_hint TEXT NOT NULL,
|
|
scopes TEXT NOT NULL,
|
|
expires_at TIMESTAMPTZ,
|
|
last_used_at TIMESTAMPTZ,
|
|
revoked_at TIMESTAMPTZ,
|
|
revoked_by INTEGER REFERENCES backend_users(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS backend_api_tokens_backend_user_id_index ON backend_api_tokens (backend_user_id)`,
|
|
} {
|
|
if err := tx.Exec(stmt).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
Rollback: func(tx *gorm.DB) error {
|
|
return tx.Exec(`DROP TABLE IF EXISTS backend_api_tokens`).Error
|
|
},
|
|
},
|
|
}
|