rbac system first stage

This commit is contained in:
2026-02-03 21:37:06 +11:00
parent 9f7e7c88a0
commit c4a4226647
38 changed files with 1966 additions and 114 deletions

View File

@@ -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")
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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),

View 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
},
)
}

View File

@@ -1,3 +1,4 @@
// Package migrations defines the database migrations to apply when using the migrate tags
package migrations
import (

View File

@@ -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...)