rbac system first stage
This commit is contained in:
@@ -9,9 +9,11 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/auditlog"
|
||||
"git.haelnorr.com/h/oslstats/internal/config"
|
||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||
"git.haelnorr.com/h/oslstats/internal/store"
|
||||
)
|
||||
|
||||
@@ -58,12 +60,21 @@ func setupHTTPServer(
|
||||
return nil, errors.Wrap(err, "httpServer.LoggerIgnorePaths")
|
||||
}
|
||||
|
||||
err = addRoutes(httpServer, &fs, cfg, bun, auth, store, discordAPI)
|
||||
// Initialize permissions checker
|
||||
perms, err := rbac.NewChecker(bun, server)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "rbac.NewChecker")
|
||||
}
|
||||
|
||||
// Initialize audit logger
|
||||
audit := auditlog.NewLogger(bun)
|
||||
|
||||
err = addRoutes(httpServer, &fs, cfg, bun, auth, store, discordAPI, perms, audit)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "addRoutes")
|
||||
}
|
||||
|
||||
err = addMiddleware(httpServer, auth, cfg)
|
||||
err = addMiddleware(httpServer, auth, cfg, perms)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "addMiddleware")
|
||||
}
|
||||
|
||||
@@ -24,6 +24,21 @@ func main() {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to load config"))
|
||||
os.Exit(1)
|
||||
}
|
||||
// Handle utility flags
|
||||
if flags.EnvDoc || flags.ShowEnv {
|
||||
if err = loader.PrintEnvVarsStdout(flags.ShowEnv); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to print env doc"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if flags.GenEnv != "" {
|
||||
if err = loader.GenerateEnvFile(flags.GenEnv, true); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to generate env file"))
|
||||
}
|
||||
return
|
||||
}
|
||||
//
|
||||
// Setup the logger
|
||||
logger, err := hlog.NewLogger(cfg.HLOG, os.Stdout)
|
||||
if err != nil {
|
||||
@@ -31,17 +46,6 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Handle utility flags
|
||||
if flags.EnvDoc || flags.ShowEnv {
|
||||
loader.PrintEnvVarsStdout(flags.ShowEnv)
|
||||
return
|
||||
}
|
||||
|
||||
if flags.GenEnv != "" {
|
||||
loader.GenerateEnvFile(flags.GenEnv, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle migration file creation (doesn't need DB connection)
|
||||
if flags.MigrateCreate != "" {
|
||||
if err := createMigration(flags.MigrateCreate); err != nil {
|
||||
@@ -55,24 +59,17 @@ func main() {
|
||||
flags.MigrateStatus || flags.MigrateDryRun ||
|
||||
flags.ResetDB {
|
||||
|
||||
// Setup database connection
|
||||
conn, close, err := setupBun(ctx, cfg)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "setupBun"))).Msg("Error setting up database")
|
||||
}
|
||||
defer close()
|
||||
|
||||
// Route to appropriate command
|
||||
if flags.MigrateUp {
|
||||
err = runMigrations(ctx, conn, cfg, "up")
|
||||
err = runMigrations(ctx, cfg, "up")
|
||||
} else if flags.MigrateRollback {
|
||||
err = runMigrations(ctx, conn, cfg, "rollback")
|
||||
err = runMigrations(ctx, cfg, "rollback")
|
||||
} else if flags.MigrateStatus {
|
||||
err = runMigrations(ctx, conn, cfg, "status")
|
||||
err = runMigrations(ctx, cfg, "status")
|
||||
} else if flags.MigrateDryRun {
|
||||
err = runMigrations(ctx, conn, cfg, "dry-run")
|
||||
err = runMigrations(ctx, cfg, "dry-run")
|
||||
} else if flags.ResetDB {
|
||||
err = resetDatabase(ctx, conn)
|
||||
err = resetDatabase(ctx, cfg)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"git.haelnorr.com/h/golib/hwsauth"
|
||||
"git.haelnorr.com/h/oslstats/internal/config"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||
"git.haelnorr.com/h/oslstats/pkg/contexts"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -19,10 +20,11 @@ func addMiddleware(
|
||||
server *hws.Server,
|
||||
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
||||
cfg *config.Config,
|
||||
perms *rbac.Checker,
|
||||
) error {
|
||||
|
||||
err := server.AddMiddleware(
|
||||
auth.Authenticate(),
|
||||
perms.LoadPermissionsMiddleware(),
|
||||
devMode(cfg),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,7 +20,13 @@ import (
|
||||
)
|
||||
|
||||
// runMigrations executes database migrations
|
||||
func runMigrations(ctx context.Context, conn *bun.DB, cfg *config.Config, command string) error {
|
||||
func runMigrations(ctx context.Context, cfg *config.Config, command string) error {
|
||||
conn, close, err := setupBun(ctx, cfg)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "setupBun")
|
||||
}
|
||||
defer close()
|
||||
|
||||
migrator := migrate.NewMigrator(conn, migrations.Migrations)
|
||||
|
||||
// Initialize migration tables
|
||||
@@ -306,7 +312,7 @@ func init() {
|
||||
`
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(filename, []byte(template), 0644); err != nil {
|
||||
if err := os.WriteFile(filename, []byte(template), 0o644); err != nil {
|
||||
return errors.Wrap(err, "write migration file")
|
||||
}
|
||||
|
||||
@@ -319,7 +325,7 @@ func init() {
|
||||
}
|
||||
|
||||
// resetDatabase drops and recreates all tables (destructive)
|
||||
func resetDatabase(ctx context.Context, conn *bun.DB) error {
|
||||
func resetDatabase(ctx context.Context, cfg *config.Config) error {
|
||||
fmt.Println("⚠️ WARNING: This will DELETE ALL DATA in the database!")
|
||||
fmt.Print("Type 'yes' to continue: ")
|
||||
|
||||
@@ -334,6 +340,11 @@ func resetDatabase(ctx context.Context, conn *bun.DB) error {
|
||||
fmt.Println("❌ Reset cancelled")
|
||||
return nil
|
||||
}
|
||||
conn, close, err := setupBun(ctx, cfg)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "setupBun")
|
||||
}
|
||||
defer close()
|
||||
|
||||
models := []any{
|
||||
(*db.User)(nil),
|
||||
|
||||
272
cmd/oslstats/migrations/20260202231414_add_rbac_system.go
Normal file
272
cmd/oslstats/migrations/20260202231414_add_rbac_system.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Migrations.MustRegister(
|
||||
// UP migration
|
||||
func(ctx context.Context, dbConn *bun.DB) error {
|
||||
// Create roles table using raw SQL to avoid m2m relationship issues
|
||||
// Bun tries to resolve relationships when creating tables from models
|
||||
// TODO: use proper m2m table instead of raw sql
|
||||
_, err := dbConn.ExecContext(ctx, `
|
||||
CREATE TABLE roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
is_system BOOLEAN DEFAULT FALSE,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create permissions table
|
||||
_, err = dbConn.NewCreateTable().
|
||||
Model((*db.Permission)(nil)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create indexes for permissions
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.NewCreateIndex().
|
||||
Model((*db.Permission)(nil)).
|
||||
Index("idx_permissions_resource").
|
||||
Column("resource").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.NewCreateIndex().
|
||||
Model((*db.Permission)(nil)).
|
||||
Index("idx_permissions_action").
|
||||
Column("action").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create role_permissions join table (Bun doesn't auto-create m2m tables)
|
||||
// TODO: use proper m2m table instead of raw sql
|
||||
_, err = dbConn.ExecContext(ctx, `
|
||||
CREATE TABLE role_permissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
||||
created_at BIGINT NOT NULL,
|
||||
UNIQUE(role_id, permission_id)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.ExecContext(ctx, `
|
||||
CREATE INDEX idx_role_permissions_role ON role_permissions(role_id)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.ExecContext(ctx, `
|
||||
CREATE INDEX idx_role_permissions_permission ON role_permissions(permission_id)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create user_roles table
|
||||
_, err = dbConn.NewCreateTable().
|
||||
Model((*db.UserRole)(nil)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create indexes for user_roles
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.NewCreateIndex().
|
||||
Model((*db.UserRole)(nil)).
|
||||
Index("idx_user_roles_user").
|
||||
Column("user_id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.NewCreateIndex().
|
||||
Model((*db.UserRole)(nil)).
|
||||
Index("idx_user_roles_role").
|
||||
Column("role_id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create audit_log table
|
||||
_, err = dbConn.NewCreateTable().
|
||||
Model((*db.AuditLog)(nil)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create indexes for audit_log
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.NewCreateIndex().
|
||||
Model((*db.AuditLog)(nil)).
|
||||
Index("idx_audit_log_user").
|
||||
Column("user_id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.NewCreateIndex().
|
||||
Model((*db.AuditLog)(nil)).
|
||||
Index("idx_audit_log_action").
|
||||
Column("action").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.NewCreateIndex().
|
||||
Model((*db.AuditLog)(nil)).
|
||||
Index("idx_audit_log_resource").
|
||||
Column("resource_type", "resource_id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: why do we need this?
|
||||
_, err = dbConn.NewCreateIndex().
|
||||
Model((*db.AuditLog)(nil)).
|
||||
Index("idx_audit_log_created").
|
||||
Column("created_at").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seed system roles
|
||||
now := time.Now().Unix()
|
||||
|
||||
adminRole := &db.Role{
|
||||
Name: "admin",
|
||||
DisplayName: "Administrator",
|
||||
Description: "Full system access with all permissions",
|
||||
IsSystem: true,
|
||||
CreatedAt: now, // TODO: this should be defaulted in table
|
||||
UpdatedAt: now, // TODO: this should be defaulted in table
|
||||
}
|
||||
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(adminRole).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
userRole := &db.Role{
|
||||
Name: "user",
|
||||
DisplayName: "User",
|
||||
Description: "Standard user with basic permissions",
|
||||
IsSystem: true,
|
||||
CreatedAt: now, // TODO: this should be defaulted in table
|
||||
UpdatedAt: now, // TODO: this should be defaulted in table
|
||||
|
||||
}
|
||||
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(userRole).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seed system permissions
|
||||
// TODO: timestamps for created should be defaulted in table
|
||||
permissionsData := []*db.Permission{
|
||||
{Name: "*", DisplayName: "Wildcard (All Permissions)", Description: "Grants access to all permissions, past, present, and future", Resource: "*", Action: "*", IsSystem: true, CreatedAt: now},
|
||||
{Name: "seasons.create", DisplayName: "Create Seasons", Description: "Create new seasons", Resource: "seasons", Action: "create", IsSystem: true, CreatedAt: now},
|
||||
{Name: "seasons.update", DisplayName: "Update Seasons", Description: "Update existing seasons", Resource: "seasons", Action: "update", IsSystem: true, CreatedAt: now},
|
||||
{Name: "seasons.delete", DisplayName: "Delete Seasons", Description: "Delete seasons", Resource: "seasons", Action: "delete", IsSystem: true, CreatedAt: now},
|
||||
{Name: "users.update", DisplayName: "Update Users", Description: "Update user information", Resource: "users", Action: "update", IsSystem: true, CreatedAt: now},
|
||||
{Name: "users.ban", DisplayName: "Ban Users", Description: "Ban users from the system", Resource: "users", Action: "ban", IsSystem: true, CreatedAt: now},
|
||||
{Name: "users.manage_roles", DisplayName: "Manage User Roles", Description: "Assign and revoke user roles", Resource: "users", Action: "manage_roles", IsSystem: true, CreatedAt: now},
|
||||
}
|
||||
|
||||
_, err = dbConn.NewInsert().
|
||||
Model(&permissionsData).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Grant wildcard permission to admin role using Bun
|
||||
// First, get the IDs
|
||||
var wildcardPerm db.Permission
|
||||
err = dbConn.NewSelect().
|
||||
Model(&wildcardPerm).
|
||||
Where("name = ?", "*").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert role_permission mapping
|
||||
// TODO: use proper m2m table, and default now in table settings
|
||||
_, err = dbConn.ExecContext(ctx, `
|
||||
INSERT INTO role_permissions (role_id, permission_id, created_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`, adminRole.ID, wildcardPerm.ID, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
// DOWN migration
|
||||
func(ctx context.Context, dbConn *bun.DB) error {
|
||||
// Drop tables in reverse order
|
||||
// Use raw SQL to avoid relationship resolution issues
|
||||
// TODO: surely we can use proper bun methods?
|
||||
tables := []string{
|
||||
"audit_log",
|
||||
"user_roles",
|
||||
"role_permissions",
|
||||
"permissions",
|
||||
"roles",
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
_, err := dbConn.ExecContext(ctx, "DROP TABLE IF EXISTS "+table+" CASCADE")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package migrations defines the database migrations to apply when using the migrate tags
|
||||
package migrations
|
||||
|
||||
import (
|
||||
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/auditlog"
|
||||
"git.haelnorr.com/h/oslstats/internal/config"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||
"git.haelnorr.com/h/oslstats/internal/store"
|
||||
)
|
||||
|
||||
@@ -23,6 +25,8 @@ func addRoutes(
|
||||
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
||||
store *store.Store,
|
||||
discordAPI *discord.APIClient,
|
||||
perms *rbac.Checker,
|
||||
audit *auditlog.Logger,
|
||||
) error {
|
||||
// Create the routes
|
||||
pageroutes := []hws.Route{
|
||||
@@ -115,8 +119,24 @@ func addRoutes(
|
||||
},
|
||||
}
|
||||
|
||||
// Admin routes
|
||||
adminRoutes := []hws.Route{
|
||||
{
|
||||
// TODO: on page load, redirect to /admin/users
|
||||
Path: "/admin",
|
||||
Method: hws.MethodGET,
|
||||
Handler: perms.RequireAdmin(s)(handlers.AdminDashboard(s, conn)),
|
||||
},
|
||||
{
|
||||
Path: "/admin/users",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: perms.RequireAdmin(s)(handlers.AdminUsersList(s, conn)),
|
||||
},
|
||||
}
|
||||
|
||||
routes := append(pageroutes, htmxRoutes...)
|
||||
routes = append(routes, wsRoutes...)
|
||||
routes = append(routes, adminRoutes...)
|
||||
|
||||
// Register the routes with the server
|
||||
err := s.AddRoutes(routes...)
|
||||
|
||||
Reference in New Issue
Block a user