Conversation
Add role-based access control with ownership tracking for generated resources. New package: pkg/authz/ - Policy interface with DefaultPolicy (admin=all, user=create+list, owner=edit+delete) - RequireRole HTTP middleware with 403 Forbidden (HTML + JSON) - CookieAuthenticator for LiveTemplate WebSocket user identification - Can(), Register(), UserFrom(), OwnedBy() helpers New command: lvt gen authz - Generates migration to add role column to users table - Appends role management queries (UpdateUserRole, GetUsersByRole) - Patches schema.sql for sqlc New flag: lvt gen resource --with-authz - Adds created_by column with FK to users table + index - Handler template: authz.Can() checks on Edit/Delete, CreatedBy on Create - Integrates CookieAuthenticator for WebSocket user identity - getUserRole() helper loads user role from DB per action Both multi and single kits updated with all template changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review:
|
| Severity | Issue |
|---|---|
| 🔴 Bug | Unused cookie import causes compile failure in generated code |
| 🔴 Bug | context.Background() in RequestDelete should use dbCtx |
| 🟠 Security | Silent authz bypass on DB lookup error |
| 🟠 Security | Unauthenticated empty-ID user can pass ownership check |
| 🟡 Correctness | Down migration doesn't remove the role column |
| 🟡 Correctness | Anonymous session ID is per-request, breaks state continuity |
| 🟡 Design | Dead TokenLookup interface |
| 🟡 Design | Fragile schema.sql string patching |
| 🔵 Minor | --with-authz not in interactive UI |
| 🔵 Minor | Duplicate single/multi kit templates |
| 🔵 Minor | Weak compile check in test |
The authz package itself (pkg/authz) is clean and well-tested. The main concerns are in the generated code template and the schema-patching logic. Happy to discuss any of these.
🤖 Generated with Claude Code
There was a problem hiding this comment.
Pull request overview
Adds first-class authorization/RBAC support to LiveTemplate’s generated apps, including an authz runtime package plus generator/templating changes to optionally emit ownership tracking and permission checks.
Changes:
- Introduces
pkg/authz(policy registry + default RBAC/ownership policy, role middleware, and cookie-based authenticator for WS identity). - Adds
lvt gen authzto scaffold arolecolumn on users (migration + queries) and patchschema.sqlfor sqlc. - Extends
lvt gen resourcewith--with-authzto addcreated_by+ handler-level authorization hooks across multi/single kits, plus new golden/validation tests.
Reviewed changes
Copilot reviewed 30 out of 30 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
pkg/authz/authz.go |
Core policy interface/registry + DefaultPolicy + ownership helpers. |
pkg/authz/middleware.go |
RequireRole middleware + 403 responder. |
pkg/authz/authenticator.go |
Cookie-based authenticator for LiveTemplate WS identity/session grouping. |
pkg/authz/*_test.go |
Unit tests for new authz primitives. |
commands/gen.go |
Adds authz subcommand routing and --with-authz flag for resource generation. |
commands/authz.go |
Implements lvt gen authz CLI command and guidance output. |
internal/generator/authz.go |
Generator for role migration/queries and schema patching. |
internal/generator/resource.go / internal/generator/types.go |
Plumbs WithAuthz into resource generation templates. |
internal/kits/system/*/templates/resource/* |
Adds created_by column/index + INSERT param + handler template hooks for authz. |
internal/kits/system/*/templates/authz/* |
Adds kit templates for authz migration/queries (schema template is comments-only). |
integration_test.go / golden_test.go / testdata/golden/* |
Adds authz-focused generation/golden validation coverage; updates callsites for new GenerateResource signature. |
internal/ui/gen_resource.go |
Updates UI resource generator callsite for new signature. |
| [[- end]] | ||
| [[- if .WithAuthz]] | ||
| "github.com/livetemplate/lvt/pkg/authz" | ||
| "github.com/livetemplate/lvt/pkg/cookie" |
There was a problem hiding this comment.
The generated handler doesn’t use pkg/cookie directly (CookieAuthenticator reads the cookie internally), so this import will be unused and will cause the generated resource handler to fail to compile. Remove the cookie import from the template (and update tests/golden accordingly).
| "github.com/livetemplate/lvt/pkg/cookie" |
| [[- end]] | ||
| [[- if .WithAuthz]] | ||
| "github.com/livetemplate/lvt/pkg/authz" | ||
| "github.com/livetemplate/lvt/pkg/cookie" |
There was a problem hiding this comment.
The generated handler doesn’t use pkg/cookie directly (CookieAuthenticator reads the cookie internally), so this import will be unused and will cause the generated resource handler to fail to compile. Remove the cookie import from the template (and update tests/golden accordingly).
| "github.com/livetemplate/lvt/pkg/cookie" |
| if item, err := c.Queries.Get[[.ResourceNameSingular]]ByID(dbCtx, input.ID); err == nil { | ||
| user := authz.UserFrom(ctx.UserID(), c.getUserRole(dbCtx, ctx.UserID())) | ||
| if !authz.Can(user, authz.ActionUpdate, "[[.TableName]]", authz.OwnedBy(item.CreatedBy)) { | ||
| [[- if .Components.UseToast]] | ||
| state.Toasts.AddError("Forbidden", "You don't have permission to edit this [[.ResourceNameLower]]") | ||
| return state, nil | ||
| [[- else]] | ||
| return state, fmt.Errorf("forbidden: you don't have permission to edit this [[.ResourceNameLower]]") | ||
| [[- end]] | ||
| } | ||
| } | ||
| [[- end]] |
There was a problem hiding this comment.
WithAuthz currently checks permissions only in the "Edit" action. The actual state-changing "Update" action can still be invoked directly (e.g., via a crafted LiveTemplate event) and will update the row without any authz.Can() check. Also, if Get…ByID fails (DB error), this code silently skips authorization and proceeds. Add an explicit authorization check in Update (and treat Get…ByID errors as an error/forbidden, not an allow).
| if item, err := c.Queries.Get[[.ResourceNameSingular]]ByID(dbCtx, input.ID); err == nil { | |
| user := authz.UserFrom(ctx.UserID(), c.getUserRole(dbCtx, ctx.UserID())) | |
| if !authz.Can(user, authz.ActionUpdate, "[[.TableName]]", authz.OwnedBy(item.CreatedBy)) { | |
| [[- if .Components.UseToast]] | |
| state.Toasts.AddError("Forbidden", "You don't have permission to edit this [[.ResourceNameLower]]") | |
| return state, nil | |
| [[- else]] | |
| return state, fmt.Errorf("forbidden: you don't have permission to edit this [[.ResourceNameLower]]") | |
| [[- end]] | |
| } | |
| } | |
| [[- end]] | |
| item, err := c.Queries.Get[[.ResourceNameSingular]]ByID(dbCtx, input.ID) | |
| if err != nil { | |
| [[- if .Components.UseToast]] | |
| state.Toasts.AddError("Forbidden", "You don't have permission to edit this [[.ResourceNameLower]]") | |
| return state, nil | |
| [[- else]] | |
| return state, fmt.Errorf("forbidden: you don't have permission to edit this [[.ResourceNameLower]]") | |
| [[- end]] | |
| } | |
| user := authz.UserFrom(ctx.UserID(), c.getUserRole(dbCtx, ctx.UserID())) | |
| if !authz.Can(user, authz.ActionUpdate, "[[.TableName]]", authz.OwnedBy(item.CreatedBy)) { | |
| [[- if .Components.UseToast]] | |
| state.Toasts.AddError("Forbidden", "You don't have permission to edit this [[.ResourceNameLower]]") | |
| return state, nil | |
| [[- else]] | |
| return state, fmt.Errorf("forbidden: you don't have permission to edit this [[.ResourceNameLower]]") | |
| [[- end]] | |
| } | |
| [[- end]] |
| if item, err := c.Queries.Get[[.ResourceNameSingular]]ByID(dbCtx, input.ID); err == nil { | ||
| user := authz.UserFrom(ctx.UserID(), c.getUserRole(dbCtx, ctx.UserID())) | ||
| if !authz.Can(user, authz.ActionUpdate, "[[.TableName]]", authz.OwnedBy(item.CreatedBy)) { | ||
| [[- if .Components.UseToast]] | ||
| state.Toasts.AddError("Forbidden", "You don't have permission to edit this [[.ResourceNameLower]]") | ||
| return state, nil | ||
| [[- else]] | ||
| return state, fmt.Errorf("forbidden: you don't have permission to edit this [[.ResourceNameLower]]") | ||
| [[- end]] | ||
| } | ||
| } | ||
| [[- end]] |
There was a problem hiding this comment.
WithAuthz currently checks permissions only in the "Edit" action. The actual state-changing "Update" action can still be invoked directly (e.g., via a crafted LiveTemplate event) and will update the row without any authz.Can() check. Also, if Get…ByID fails (DB error), this code silently skips authorization and proceeds. Add an explicit authorization check in Update (and treat Get…ByID errors as an error/forbidden, not an allow).
| if item, err := c.Queries.Get[[.ResourceNameSingular]]ByID(dbCtx, input.ID); err == nil { | |
| user := authz.UserFrom(ctx.UserID(), c.getUserRole(dbCtx, ctx.UserID())) | |
| if !authz.Can(user, authz.ActionUpdate, "[[.TableName]]", authz.OwnedBy(item.CreatedBy)) { | |
| [[- if .Components.UseToast]] | |
| state.Toasts.AddError("Forbidden", "You don't have permission to edit this [[.ResourceNameLower]]") | |
| return state, nil | |
| [[- else]] | |
| return state, fmt.Errorf("forbidden: you don't have permission to edit this [[.ResourceNameLower]]") | |
| [[- end]] | |
| } | |
| } | |
| [[- end]] | |
| item, err := c.Queries.Get[[.ResourceNameSingular]]ByID(dbCtx, input.ID) | |
| if err != nil { | |
| [[- if .Components.UseToast]] | |
| state.Toasts.AddError("Forbidden", "You don't have permission to edit this [[.ResourceNameLower]]") | |
| return state, nil | |
| [[- else]] | |
| return state, fmt.Errorf("forbidden: failed to load [[.ResourceNameLower]] for editing: %w", err) | |
| [[- end]] | |
| } | |
| user := authz.UserFrom(ctx.UserID(), c.getUserRole(dbCtx, ctx.UserID())) | |
| if !authz.Can(user, authz.ActionUpdate, "[[.TableName]]", authz.OwnedBy(item.CreatedBy)) { | |
| [[- if .Components.UseToast]] | |
| state.Toasts.AddError("Forbidden", "You don't have permission to edit this [[.ResourceNameLower]]") | |
| return state, nil | |
| [[- else]] | |
| return state, fmt.Errorf("forbidden: you don't have permission to edit this [[.ResourceNameLower]]") | |
| [[- end]] | |
| } | |
| [[- end]] |
| [[- if .WithAuthz]] | ||
| // Check authorization | ||
| if item, err := c.Queries.Get[[.ResourceNameSingular]]ByID(context.Background(), input.ID); err == nil { | ||
| user := authz.UserFrom(ctx.UserID(), c.getUserRole(context.Background(), ctx.UserID())) | ||
| if !authz.Can(user, authz.ActionDelete, "[[.TableName]]", authz.OwnedBy(item.CreatedBy)) { | ||
| [[- if .Components.UseToast]] | ||
| state.Toasts.AddError("Forbidden", "You don't have permission to delete this [[.ResourceNameLower]]") | ||
| return state, nil | ||
| [[- else]] | ||
| return state, fmt.Errorf("forbidden: you don't have permission to delete this [[.ResourceNameLower]]") | ||
| [[- end]] | ||
| } | ||
| } |
There was a problem hiding this comment.
WithAuthz currently checks permissions only when showing the confirmation modal (RequestDelete). The destructive ConfirmDelete action can still be invoked directly and will delete the row without any authz.Can() check. Also, DB errors from Get…ByID currently bypass the authz check. Add an explicit authz.Can() check in ConfirmDelete (and don’t treat Get…ByID errors as allow).
| func (p *DefaultPolicy) Can(user User, action string, resource any) bool { | ||
| if user == nil { | ||
| return false | ||
| } | ||
| if user.GetRole() == RoleAdmin { | ||
| return true | ||
| } | ||
| switch action { | ||
| case ActionCreate, ActionList: | ||
| return true | ||
| case ActionRead: | ||
| return true | ||
| case ActionUpdate, ActionDelete: | ||
| if ownable, ok := resource.(Ownable); ok { | ||
| return ownable.GetCreatedBy() == user.GetID() | ||
| } | ||
| return true | ||
| } | ||
| return false |
There was a problem hiding this comment.
DefaultPolicy currently treats any non-nil User as “authenticated”, even if user.GetID() is empty. Since generated code uses UserFrom(ctx.UserID(), role), an unauthenticated request (empty ctx.UserID()) will still be allowed to create/list/read. Also, for update/delete when the resource does not implement Ownable, the policy returns true (allow) which contradicts the doc comment (“owner can …; non-owner cannot”). Consider denying when user.GetID()=="" and denying update/delete when ownership can’t be determined.
| // Check if role column already exists | ||
| if strings.Contains(content, "role TEXT") { | ||
| return nil // Already patched | ||
| } | ||
|
|
||
| // Find the users table and add role before created_at | ||
| // Pattern: " created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," | ||
| // in the users table (identified by the table name) | ||
| target := " created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at" | ||
| replacement := " role TEXT NOT NULL DEFAULT 'user',\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at" | ||
|
|
||
| if !strings.Contains(content, target) { | ||
| // Try alternative pattern without updated_at on next line | ||
| target = " created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)" | ||
| replacement = " role TEXT NOT NULL DEFAULT 'user',\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)" | ||
| } | ||
|
|
||
| if !strings.Contains(content, target) { | ||
| return fmt.Errorf("could not find insertion point for role column in schema.sql — add manually: role TEXT NOT NULL DEFAULT 'user'") | ||
| } | ||
|
|
||
| content = strings.Replace(content, target, replacement, 1) |
There was a problem hiding this comment.
patchSchemaWithRole ignores the tableName parameter and does a global strings.Contains(content, "role TEXT") check, which can incorrectly skip patching if any other table already has a role column. It also finds the insertion point via a hard-coded created_at … updated_at substring without scoping to the intended CREATE TABLE for the configured table. Please scope both the “already patched” check and the insertion search to the specific CREATE TABLE for tableName (or use a small SQL parser / more robust regex).
| // Check if role column already exists | |
| if strings.Contains(content, "role TEXT") { | |
| return nil // Already patched | |
| } | |
| // Find the users table and add role before created_at | |
| // Pattern: " created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," | |
| // in the users table (identified by the table name) | |
| target := " created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at" | |
| replacement := " role TEXT NOT NULL DEFAULT 'user',\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at" | |
| if !strings.Contains(content, target) { | |
| // Try alternative pattern without updated_at on next line | |
| target = " created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)" | |
| replacement = " role TEXT NOT NULL DEFAULT 'user',\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)" | |
| } | |
| if !strings.Contains(content, target) { | |
| return fmt.Errorf("could not find insertion point for role column in schema.sql — add manually: role TEXT NOT NULL DEFAULT 'user'") | |
| } | |
| content = strings.Replace(content, target, replacement, 1) | |
| // Locate the CREATE TABLE block for the specified table. | |
| tableMarker := "CREATE TABLE " + tableName | |
| start := strings.Index(content, tableMarker) | |
| if start == -1 { | |
| return fmt.Errorf("could not find CREATE TABLE statement for %q in schema.sql", tableName) | |
| } | |
| // Find the end of the table definition (first occurrence of "); | |
| // after the CREATE TABLE for this table). | |
| rest := content[start:] | |
| endOffset := strings.Index(rest, ");") | |
| if endOffset == -1 { | |
| return fmt.Errorf("could not determine end of CREATE TABLE statement for %q in schema.sql", tableName) | |
| } | |
| end := start + endOffset + len(");") | |
| tableDef := content[start:end] | |
| // Check if role column already exists in this table. | |
| if strings.Contains(tableDef, "role TEXT") { | |
| return nil // Already patched for this table. | |
| } | |
| // Find the insertion point within this table definition and add role before created_at. | |
| // Pattern: " created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," | |
| // in the target table (identified by the table name). | |
| target := " created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at" | |
| replacement := " role TEXT NOT NULL DEFAULT 'user',\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n updated_at" | |
| if !strings.Contains(tableDef, target) { | |
| // Try alternative pattern without updated_at on next line. | |
| target = " created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)" | |
| replacement = " role TEXT NOT NULL DEFAULT 'user',\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)" | |
| } | |
| if !strings.Contains(tableDef, target) { | |
| return fmt.Errorf("could not find insertion point for role column in schema.sql for table %q — add manually: role TEXT NOT NULL DEFAULT 'user'", tableName) | |
| } | |
| patchedTableDef := strings.Replace(tableDef, target, replacement, 1) | |
| content = content[:start] + patchedTableDef + content[end:] |
| // Parse flags (removed --css and --mode, they're now locked in config) | ||
| paginationMode := "infinite" // default | ||
| pageSize := 20 // default | ||
| editMode := "modal" // default | ||
| skipValidation := false | ||
| parentResource := "" | ||
| withAuthz := false | ||
| var filteredArgs []string | ||
| for i := 0; i < len(args); i++ { | ||
| if args[i] == "--pagination" && i+1 < len(args) { | ||
| paginationMode = args[i+1] | ||
| i++ // skip next arg | ||
| } else if args[i] == "--page-size" && i+1 < len(args) { | ||
| if size, err := fmt.Sscanf(args[i+1], "%d", &pageSize); err != nil || size == 0 || pageSize < 1 { | ||
| pageSize = 20 // fallback to default | ||
| } | ||
| i++ // skip next arg | ||
| } else if args[i] == "--edit-mode" && i+1 < len(args) { | ||
| editMode = args[i+1] | ||
| i++ // skip next arg | ||
| } else if args[i] == "--skip-validation" { | ||
| skipValidation = true | ||
| } else if args[i] == "--parent" && i+1 < len(args) { | ||
| parentResource = args[i+1] | ||
| i++ // skip next arg | ||
| } else if args[i] == "--with-authz" { | ||
| withAuthz = true | ||
| } else { | ||
| filteredArgs = append(filteredArgs, args[i]) | ||
| } |
There was a problem hiding this comment.
--with-authz can be enabled even when the auth system hasn’t been generated. In that case, the generated resource will reference users(id) and auth queries (GetUserByID/GetUserToken) that won’t exist, and migrations will fail. Add a preflight validation when withAuthz==true (e.g., require app/auth and a users table schema) and return a clear error instructing the user to run lvt gen auth / lvt gen authz first.
| // Verify handler has valid Go syntax | ||
| cmd := exec.Command("go", "tool", "compile", "-o", "/dev/null", handlerPath) | ||
| output, _ := cmd.CombinedOutput() | ||
| if strings.Contains(string(output), "syntax error") { | ||
| t.Errorf("Handler has syntax errors:\n%s", output) |
There was a problem hiding this comment.
This compile check only reports failures if the output contains the substring "syntax error" and ignores the command exit status. It will miss important compile errors (e.g., unused imports, missing identifiers, type errors). Check cmd.Run()/exit code and fail the test on any non-zero result, and update handlerChecks accordingly (especially if the unused cookie import is removed).
| // Verify handler has valid Go syntax | |
| cmd := exec.Command("go", "tool", "compile", "-o", "/dev/null", handlerPath) | |
| output, _ := cmd.CombinedOutput() | |
| if strings.Contains(string(output), "syntax error") { | |
| t.Errorf("Handler has syntax errors:\n%s", output) | |
| // Verify handler has valid Go syntax (and compiles successfully) | |
| cmd := exec.Command("go", "tool", "compile", "-o", "/dev/null", handlerPath) | |
| output, err := cmd.CombinedOutput() | |
| if err != nil { | |
| t.Errorf("Handler failed to compile: %v\nCompiler output:\n%s", err, string(output)) |
| // TokenLookup is the interface for looking up a user ID from a session token. | ||
| // The generated sqlc Queries struct satisfies this via its GetUserToken method. | ||
| type TokenLookup interface { | ||
| GetUserToken(ctx context.Context, arg interface{ GetToken() string; GetNow() time.Time }) (interface{ GetUserID() string }, error) | ||
| } | ||
|
|
||
| // CookieAuthenticator implements livetemplate.Authenticator by reading |
There was a problem hiding this comment.
The TokenLookup interface and its comment are misleading: a sqlc-generated Queries.GetUserToken method typically takes a concrete params struct and returns a concrete row type, so it will not satisfy this interface. Either remove TokenLookup (it’s unused) or adjust the comment/interface to match actual generated signatures (or avoid claiming compatibility).
| // TokenLookup is the interface for looking up a user ID from a session token. | |
| // The generated sqlc Queries struct satisfies this via its GetUserToken method. | |
| type TokenLookup interface { | |
| GetUserToken(ctx context.Context, arg interface{ GetToken() string; GetNow() time.Time }) (interface{ GetUserID() string }, error) | |
| } | |
| // CookieAuthenticator implements livetemplate.Authenticator by reading | |
| // CookieAuthenticator implements livetemplate.Authenticator by reading | |
| // the auth session cookie to identify the user during WebSocket setup. | |
| // This enables ctx.UserID() to return the real authenticated user ID | |
| // in LiveTemplate controller actions. | |
| type CookieAuthenticator struct { | |
| cookieName string | |
| lookupFn func(ctx context.Context, token string) (userID string, err error) | |
| } |
- Remove unused cookie import from handler template (would cause compile error) - Remove dead TokenLookup interface from authenticator.go - Deduplicate golden test — extend runHandlerGoldenTest with withAuthz variadic param - Add safety limit (maxRetries) to migration timestamp loop + handle Glob error - Remove redundant auth-exists check from commands/authz.go (generator owns it) - Use cookie.Get() consistently in GetSessionGroup - Remove narrating comments from authz.go Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test caught a real bug: the handler template referenced
GetUserTokenParams{Token: token, Now: time.Now()} but the actual
sqlc-generated struct uses {Token: token, ExpiresAt: sql.NullTime{...}}.
Fixes:
- Fix GetUserTokenParams field names (Token + ExpiresAt, not Now)
- Add database/sql import for sql.NullTime when WithAuthz is set
- Verify sqlc generates Role on User and CreatedBy on Post
- Full pipeline: gen app → gen auth → gen authz → gen resource
--with-authz → go mod tidy → sqlc generate → go build succeeds
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Code Review: feat: add authorization / RBAC system Solid feature overall. The pkg/authz package is well-structured, tests are comprehensive, and the golden file approach for handler validation is great. Issues below are ordered by severity. SECURITY - Fail-open authorization bypass on DB error: In both Edit and RequestDelete templates, the permission check uses the pattern 'if item, err := ...; err == nil'. If the DB is unavailable, auth is silently skipped. This is fail-open. Instead, fetch the item unconditionally, return an error on failure, then check permissions. SECURITY - Unauthenticated writes with empty created_by: DefaultPolicy.Can returns true for ActionCreate for any non-nil User. UserFrom with empty strings is non-nil. If ctx.UserID() returns empty string due to invalid or expired cookie, CreatedBy gets an empty string, violating NOT NULL or corrupting ownership tracking. Guard against empty UserID in the generated Add handler, or have DefaultPolicy reject ActionCreate when the user ID is empty. BUG - context.Background() in RequestDelete template: Edit uses dbCtx correctly but RequestDelete uses context.Background() for both the auth check and getUserRole. Request cancellation and deadlines will not propagate for deletes. Change both to dbCtx. FRAGILE - patchSchemaWithRole hard-coded string patterns: Two hard-coded patterns find the role column insertion point. Different formatting in the auth schema will break this. Consider regex, or generate the role column as part of lvt gen auth from the start. PERFORMANCE - getUserRole queries DB on every permission check: Every Edit and Delete fires two DB queries: one for the item, one for the user role. The role does not change within a request. Cache it in LiveTemplate session state or fetch both in one query. API DESIGN - GenerateResource now has 13 positional parameters: 15+ test call sites all had to be updated for this one flag. An options struct (ResourceOptions) would make future additions non-breaking. DEAD CODE - authz/schema.sql.tmpl contains only comments: The file has no SQL. It is loaded by kitLoader but never used. Remove it or document in GenerateAuthz why it exists. IGNORED ERROR - qFile.WriteString in GenerateAuthz: The error from WriteString is silently discarded. It should be checked and propagated. MIGRATION - Down does not roll back the column: After rollback the role column remains due to SQLite limitations. Document this in the generated migration comments, or include a table-rebuild in the Down section. WHAT IS WORKING WELL: Clean pkg/authz design (Policy interface, DefaultPolicy, UserFrom/OwnedBy helpers are ergonomic and easy to extend). CookieAuthenticator is well-tested with a nice anonymous session group fallback. Golden test for --with-authz is a solid regression anchor. TestAuthzFullFlow doing a real go build + sqlc generate is exactly the right end-to-end validation. Good unit test coverage across authz.go, authenticator.go, and middleware.go. Generated with Claude Code |
Security fixes: - Add authz.Can() check to Update action (was only on Edit, not the actual state-changing action) - Add authz.Can() check to ConfirmDelete (was only on RequestDelete) - Guard CreatedBy assignment: return error when ctx.UserID() is empty - DefaultPolicy: deny when user ID is empty, deny update/delete on non-ownable resources (was incorrectly allowing) - Validate --with-authz requires auth system to exist Tests: - Add tests for empty user ID denial and non-ownable resource denial - Fix go fmt formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code Review: feat: add authorization / RBAC system (Roadmap 2.3)Overall this is a solid, well-structured addition. The Bugs / Correctness1. Fragile schema patching in The function patches 2. Prerequisite check is incomplete ( if _, err := os.Stat(filepath.Join(basePath, "app", "auth")); os.IsNotExist(err) {
return fmt.Errorf("--with-authz requires authentication. Run 'lvt gen auth' and 'lvt gen authz' first")
}This only checks that 3. Ignored write error ( qFile.WriteString("\n\n")Error return is discarded. Should be: if _, err := qFile.WriteString("\n\n"); err != nil {
qFile.Close()
return fmt.Errorf("failed to write separator to queries.sql: %w", err)
}4. Incomplete down migration ( The Performance5. Double DB fetch for authz in In 6. Since LiveTemplate uses long-lived WebSocket connections, the user's role is re-fetched from the database on every Design / API7. Adding 8. Dead template file ( This file is 12 lines of comments explaining that it doesn't do anything. The actual patching is done by Minor9. authz.Register("[[.TableName]]", &authz.DefaultPolicy{})The registration key is the table name (e.g., 10. The 3× What's working well
🤖 Generated with Claude Code |
Summary
pkg/authz/package withPolicyinterface,DefaultPolicy(admin=all, user=create+list, owner=edit+delete),RequireRolemiddleware, andCookieAuthenticatorfor LiveTemplate WebSocket identitylvt gen authzcommand that adds arole TEXT DEFAULT 'user'column to the users table with management queries--with-authzflag onlvt gen resourcethat addscreated_bycolumn, ownership tracking in Create handler, andauthz.Can()permission checks in Edit/Delete handlersTest plan
🤖 Generated with Claude Code