Skip to content

feat: add authorization / RBAC system (Roadmap 2.3)#273

Merged
adnaan merged 4 commits intomainfrom
authz
Mar 22, 2026
Merged

feat: add authorization / RBAC system (Roadmap 2.3)#273
adnaan merged 4 commits intomainfrom
authz

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Mar 21, 2026

Summary

  • New pkg/authz/ package with Policy interface, DefaultPolicy (admin=all, user=create+list, owner=edit+delete), RequireRole middleware, and CookieAuthenticator for LiveTemplate WebSocket identity
  • New lvt gen authz command that adds a role TEXT DEFAULT 'user' column to the users table with management queries
  • New --with-authz flag on lvt gen resource that adds created_by column, ownership tracking in Create handler, and authz.Can() permission checks in Edit/Delete handlers
  • Both multi and single kit templates updated

Test plan

  • Unit tests: DefaultPolicy (admin/user/owner), RequireRole middleware, CookieAuthenticator, OwnedBy helper
  • Golden test: handler snapshot for resource with --with-authz
  • Content validation: handler has authz imports, Can() calls, CreatedBy, CookieAuthenticator
  • SQL validation: schema/migration have created_by column + index, queries have created_by in INSERT
  • Syntax check: generated handler with authz has no syntax errors
  • All 31 test packages pass (zero regressions)

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings March 21, 2026 22:15
@claude
Copy link
Copy Markdown

claude bot commented Mar 21, 2026

PR Review: feat: add authorization / RBAC system (Roadmap 2.3)

Overall this is a solid, well-structured addition. The pkg/authz package has clean interfaces, good test coverage, and the generated code pattern is clear. A few issues worth addressing before merge:


Bugs

1. Unused cookie import in generated handler — compile failure

In handler.go.tmpl (both multi and single), the cookie package is imported but never directly used in the generated handler code. cookie.Get is called internally by pkg/authz, not by the generated code itself. This will produce an imported and not used compile error on go build.

The golden file includes this import but the syntax-check test only looks for the string "syntax error" in the compiler output, so it slips through. Either remove the cookie import from the template, or use _ "..." if it's needed for a side effect (it isn't).

2. context.Background() in RequestDelete vs dbCtx in Edit

In the template, the Edit handler correctly uses dbCtx for the authz lookup:

if item, err := c.Queries.Get[[.ResourceNameSingular]]ByID(dbCtx, input.ID); err == nil {

But RequestDelete uses context.Background():

if item, err := c.Queries.Get[[.ResourceNameSingular]]ByID(context.Background(), input.ID); err == nil {

This should use dbCtx (or equivalent) for consistency and to respect request-scoped timeouts/cancellation.

3. DB error silently bypasses authz check

Both Edit and RequestDelete use if err == nil { ... } — meaning if the GetByID query fails (item not found, DB unavailable), the authorization check is skipped entirely and the operation proceeds. This is surprising: a missing item should return a clear error rather than silently proceeding to the update/delete path (which will also fail, but less clearly).

// Current: skip authz if item lookup fails
if item, err := c.Queries.GetPostByID(dbCtx, input.ID); err == nil {
    // authz check
}
// ... update proceeds regardless

// Better: return early on lookup failure
item, err := c.Queries.GetPostByID(dbCtx, input.ID)
if err != nil {
    return state, fmt.Errorf("item not found: %w", err)
}
// authz check on item

Security Concerns

4. Unauthenticated users can pass ownership check with empty created_by

When ctx.UserID() returns "", getUserRole short-circuits and returns "". UserFrom("", "") creates a non-nil user with an empty ID. In DefaultPolicy.Can, ActionUpdate/ActionDelete check:

return ownable.GetCreatedBy() == user.GetID()  // "" == "" → true

In practice the created_by TEXT NOT NULL REFERENCES users(id) constraint prevents empty created_by from being stored, so this isn't exploitable today. But it's a latent issue if the constraint is relaxed. Worth adding a guard in DefaultPolicy.Can:

case ActionUpdate, ActionDelete:
    if user.GetID() == "" {
        return false  // unauthenticated users can never own resources
    }
    ...

Down Migration Is Incomplete

5. authz/migration.sql.tmpl Down section doesn't remove the column

The Down migration drops the index but leaves the role column in place:

-- +goose Down
-- +goose StatementBegin
-- SQLite does not support DROP COLUMN directly.
-- To remove the role column, recreate the table without it.
DROP INDEX IF EXISTS idx_{{.TableName}}_role;
-- +goose StatementEnd

Since this is SQLite-targeted, consider either: (a) providing the full table-recreation script, or (b) documenting clearly that the Down migration is intentionally a no-op for column removal.


Design / Code Quality

6. Unused TokenLookup interface (pkg/authz/authenticator.go)

The TokenLookup interface (lines 1182–1184) defines a complex structural interface but is never used anywhere — CookieAuthenticator takes a plain func instead. This is dead code and should be removed.

7. Anonymous session ID is per-request, not per-browser

In GetSessionGroup, the fallback for users with no livetemplate-id cookie:

return fmt.Sprintf("anon-%d", time.Now().UnixNano()), nil

This generates a unique session ID for every request, meaning each WebSocket connection from the same anonymous browser gets a different session. LiveTemplate state won't be shared across tabs/reconnects for anonymous users. This should probably use a random UUID written into a new cookie, similar to how livetemplate-id itself is typically set.

8. Fragile patchSchemaWithRole string matching

The function tries two exact string patterns to find the insertion point in schema.sql. If the schema was generated with different whitespace or column ordering (e.g., updated_at before created_at), both patterns fail and the user gets a confusing error. Consider a more robust approach (e.g., regex or parsing the SQL structure, or at minimum, trying more pattern variants).

9. --with-authz not available in interactive UI (internal/ui/gen_resource.go)

The interactive lvt gen resource UI hardcodes withAuthz: false. The flag is only available via CLI. This is a minor gap worth noting — either add it to the interactive flow or document that --with-authz is CLI-only.

10. Duplicate template files between kits

single/templates/authz/ and multi/templates/authz/ are byte-for-byte identical. If these always stay in sync, they should either be shared from a common location or at minimum have a test asserting their equivalence.


Minor

11. Weak syntax check in integration test

output, _ := cmd.CombinedOutput()
if strings.Contains(string(output), "syntax error") {

This only catches the string "syntax error" and ignores the process exit code. Other compile errors (unused imports, type mismatches) are silently missed. The exit code from cmd.Run() or cmd.CombinedOutput() being non-nil is the right signal:

if output, err := cmd.CombinedOutput(); err != nil {
    t.Errorf("Handler has compile errors:\n%s", output)
}

Summary

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

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 authz to scaffold a role column on users (migration + queries) and patch schema.sql for sqlc.
  • Extends lvt gen resource with --with-authz to add created_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"
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
"github.com/livetemplate/lvt/pkg/cookie"

Copilot uses AI. Check for mistakes.
[[- end]]
[[- if .WithAuthz]]
"github.com/livetemplate/lvt/pkg/authz"
"github.com/livetemplate/lvt/pkg/cookie"
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
"github.com/livetemplate/lvt/pkg/cookie"

Copilot uses AI. Check for mistakes.
Comment on lines +217 to +228
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]]
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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]]

Copilot uses AI. Check for mistakes.
Comment on lines +217 to +228
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]]
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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]]

Copilot uses AI. Check for mistakes.
Comment on lines +417 to +429
[[- 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]]
}
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment thread pkg/authz/authz.go
Comment on lines +47 to +65
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
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +152
// 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)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
// 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:]

Copilot uses AI. Check for mistakes.
Comment thread commands/gen.go
Comment on lines 113 to 142
// 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])
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot uses AI. Check for mistakes.
Comment thread integration_test.go
Comment on lines +780 to +784
// 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)
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
// 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))

Copilot uses AI. Check for mistakes.
Comment thread pkg/authz/authenticator.go Outdated
Comment on lines +12 to +18
// 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
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
// 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)
}

Copilot uses AI. Check for mistakes.
adnaan and others added 2 commits March 22, 2026 00:10
- 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>
@claude
Copy link
Copy Markdown

claude bot commented Mar 21, 2026

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>
@claude
Copy link
Copy Markdown

claude bot commented Mar 22, 2026

Code Review: feat: add authorization / RBAC system (Roadmap 2.3)

Overall this is a solid, well-structured addition. The pkg/authz package is clean, the DefaultPolicy is sensible, and the test coverage is good. A few issues worth addressing before merge:


Bugs / Correctness

1. Fragile schema patching in patchSchemaWithRole (internal/generator/authz.go:120-156)

The function patches schema.sql using hardcoded string matching against specific whitespace and column ordering. If the auth generator ever changes its output format even slightly, this silently fails with the unhelpful message "could not find insertion point for role column". Consider parsing the SQL structurally or anchoring the pattern to the table name so it only patches the right CREATE TABLE block. Right now strings.Contains(content, target) could also match the wrong table if schemas follow the same pattern.

2. Prerequisite check is incomplete (commands/gen.go:151-154)

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 lvt gen auth was run. A user can run lvt gen authlvt gen resource posts --with-authz (skipping lvt gen authz) and the generated code will reference user.Role on the sqlc model, which won't exist because the role column was never added. Should also check that the role column is present in schema.sql, or that the authz migration directory has been generated.

3. Ignored write error (internal/generator/authz.go:119)

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 (internal/kits/system/multi/templates/authz/migration.sql.tmpl)

The -- +goose Down section only drops the index, not the column. The comment says SQLite doesn't support DROP COLUMN, but the migration is still advertised as reversible. Either mark the migration as irreversible or document clearly that the down migration is partial. Users running lvt migration down expecting a full rollback will be surprised.


Performance

5. Double DB fetch for authz in Edit handler (template handler.go.tmpl)

In Edit, the item is fetched for the authz check, then GetAll[[.ResourceNamePlural]] is called separately. In ConfirmDelete, the item is fetched in both RequestDelete and ConfirmDelete for the re-check. This is intentional TOCTOU defense for the delete case and is fine, but the Edit / Update pair fetches the item twice (once for the check, once to find it in the list). Consider reusing the fetched item.

6. getUserRole hits the DB on every action

Since LiveTemplate uses long-lived WebSocket connections, the user's role is re-fetched from the database on every Edit, Update, RequestDelete, and ConfirmDelete. Consider caching the role in the controller state or session for the connection's lifetime, since roles change infrequently.


Design / API

7. GenerateResource positional bool parameter

Adding withAuthz bool as the 13th positional parameter makes an already long signature harder to maintain. The ~10 call-site updates to pass false show the fragility. Consider introducing a ResourceOptions struct for optional flags (WithAuthz, PaginationMode, PageSize, EditMode, ParentResource) that groups these knobs together.

8. Dead template file (internal/kits/system/multi/templates/authz/schema.sql.tmpl)

This file is 12 lines of comments explaining that it doesn't do anything. The actual patching is done by patchSchemaWithRole. The file is never loaded by the generator (the LoadKitTemplate call in authz.go only loads authz/migration.sql.tmpl and authz/queries.sql.tmpl). Remove it to avoid confusion.


Minor

9. authz.Register uses table name as the key (handler.go.tmpl:init())

authz.Register("[[.TableName]]", &authz.DefaultPolicy{})

The registration key is the table name (e.g., "posts"), but the Can() call also uses "[[.TableName]]". This is internally consistent but the public API docs for Register say "resource type name", which could confuse users trying to register custom policies — they'd need to know to use the table name, not the Go type name.

10. time.Sleep in TestAuthzFullFlow (integration_test.go)

The 3× time.Sleep(2 * time.Second) between generation steps should have a comment explaining why (timestamp collision prevention for migration filenames). Without context, this looks like a flaky-test workaround.


What's working well

  • pkg/authz package is clean with a well-defined Policy interface and sensible DefaultPolicy defaults
  • OwnedBy() and UserFrom() adapters are a nice touch — avoids forcing generated models to implement interfaces
  • Re-checking auth in ConfirmDelete (TOCTOU defense) is exactly right for destructive actions
  • Test coverage is comprehensive: unit tests, golden file tests, content validation, full flow test with real sqlc + go build
  • The CookieAuthenticator.Identify returning "", nil (not an error) for invalid/expired tokens is the correct behavior for optional auth
  • Template delimiter consistency within each template system is maintained

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants