Skip to content

feat(auth): add API key path scoping and workflow auth node styling#197

Merged
SantiagoDePolonia merged 3 commits intomainfrom
feature/api-keys-per-path
Mar 31, 2026
Merged

feat(auth): add API key path scoping and workflow auth node styling#197
SantiagoDePolonia merged 3 commits intomainfrom
feature/api-keys-per-path

Conversation

@SantiagoDePolonia
Copy link
Copy Markdown
Contributor

@SantiagoDePolonia SantiagoDePolonia commented Mar 31, 2026

Summary

  • add optional user-path scoping to managed API keys across storage, admin API, auth middleware, and dashboard UI
  • apply managed-key user-path overrides before execution planning so scoped policies resolve correctly
  • style the workflow/execution-plan Auth node like Cache by default and cover it with layout tests

Testing

  • go test ./internal/server ./internal/authkeys ./internal/auditlog ./internal/core ./internal/admin ./internal/app
  • node --test internal/admin/dashboard/static/js/modules/auth-keys.test.js internal/admin/dashboard/static/js/modules/dashboard-layout.test.js internal/admin/dashboard/static/js/modules/execution-plans-layout.test.js

Summary by CodeRabbit

  • New Features

    • Added an optional "User Path" field to API key creation; displays in the API keys table and can override the X-GoModel-User-Path header for request context and audit logging.
    • Authenticated API keys can supply an effective User Path used during request handling and execution planning.
  • Bug Fixes / Validation

    • User Path input is validated and normalized to prevent path-traversal and collapse redundant slashes.
  • Style

    • Improved Auth node styling and updated Auth node icon in execution-plan visuals.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 31, 2026

📝 Walkthrough

Walkthrough

Adds optional per-auth-key user_path: UI input, validation/normalization, persistence across stores, propagation through authentication middleware into request context and audit logs; execution-plan middleware ordering adjusted so managed key user_path is applied before plan resolution; tests updated accordingly.

Changes

Cohort / File(s) Summary
Dashboard UI & JS
internal/admin/dashboard/static/js/modules/auth-keys.js, internal/admin/dashboard/static/js/modules/auth-keys.test.js, internal/admin/dashboard/templates/index.html
Add user_path form field, validation and normalization helpers, include user_path in POST payloads; tests updated to cover validation and serialized value and UI bindings.
Execution-plan UI & CSS
internal/admin/dashboard/static/css/dashboard.css, internal/admin/dashboard/templates/execution-plan-chart.html, internal/admin/dashboard/static/js/modules/execution-plans-layout.test.js
Add .ep-node-auth styling and sub-element rules; replace Auth node SVG icon; tests assert .ep-node-auth theming and SVG/label rendering.
Admin handler
internal/admin/handler.go, internal/admin/handler_authkeys_test.go
Accept optional user_path in create request, normalize via normalizeUserPathQueryParam, return 400 on invalid input; tests for normalization and traversal rejection.
Auth keys types/service/store
internal/authkeys/types.go, internal/authkeys/service.go, internal/authkeys/service_test.go, internal/authkeys/store.go, internal/authkeys/store_mongodb.go, internal/authkeys/store_postgresql.go, internal/authkeys/store_sqlite.go
Persist UserPath on AuthKey and CreateInput; introduce AuthenticationResult{ID, UserPath} and update Authenticate signature/returns; normalize/validate user_path in store layer; DB migrations/queries updated to read/write user_path.
Server auth & routing
internal/server/auth.go, internal/server/auth_test.go, internal/server/http.go, internal/server/http_test.go
BearerTokenAuthenticator.Authenticate now returns AuthenticationResult; middleware applies UserPath as effective override (context, snapshot header, audit enrichment); moved execution-planning middleware to run after auth; tests added to assert override behavior before execution planning.
Core context & request snapshot
internal/core/context.go, internal/core/request_snapshot.go, internal/core/user_path.go, internal/core/request_snapshot_test.go, internal/core/user_path_test.go
Add WithEffectiveUserPath / GetEffectiveUserPath; RequestSnapshot.WithUserPath to rewrite snapshot and headers; UserPathFromContext prefers effective override; tests added.
Audit logging
internal/auditlog/middleware.go, internal/auditlog/auditlog_test.go
applyAuthentication sets entry.UserPath from context; add EnrichEntryWithUserPath helper; test verifies effective user path stored in log entry.
Dashboard layout tests
internal/admin/dashboard/static/js/modules/dashboard-layout.test.js
Add assertions for authKeyForm.user_path bindings, rendering in table rows, and X-GoModel-User-Path marker.
CSS-only tweak
internal/admin/dashboard/static/css/dashboard.css
New base .ep-node-auth rules and sub-element color mixing added (no runtime-state classes changed).

Sequence Diagram

sequenceDiagram
    participant Client
    participant Server
    participant AuthService as Auth Service
    participant Context as Context Helpers
    participant Audit as Audit Log
    participant ExecPlan as Execution Planner

    Client->>Server: HTTP request (Authorization: Bearer <token>, User-Path header)
    Server->>AuthService: Authenticate(token)
    AuthService-->>Server: AuthenticationResult { ID, UserPath }
    Server->>Context: WithAuthKeyID(ctx, ID)
    Server->>Context: WithEffectiveUserPath(ctx, UserPath) 
    Server->>Server: RequestSnapshot.WithUserPath(UserPath)
    Server->>Audit: EnrichEntryWithUserPath(UserPath)
    Server->>ExecPlan: ExecutionPlanningWithResolverAndPolicy(ctx with effective UserPath)
    ExecPlan-->>Server: Plan resolved using effective UserPath
    Server->>Client: Handler response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I thumped my paw on keystrokes bright,

Paths now travel with each key in flight,
Through headers, logs, and context lanes,
The audit keeps the trail it gains —
Hooray! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the two main changes: API key path scoping and auth node styling in workflows.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/api-keys-per-path

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/admin/dashboard/templates/execution-plan-chart.html (1)

14-20: 🧹 Nitpick | 🔵 Trivial

Auth and Cache nodes use identical icons and styling—consider distinct visual treatment for clarity.

Both the Auth and Cache nodes use the exact same SVG (database/cylinder icon) and share identical CSS styling (same --accent color, border, background). This makes them visually indistinguishable except for their text labels, which could confuse users when scanning the execution pipeline visually.

Since these represent functionally different workflow stages, they should have distinct visual identities. Consider using a unique icon for Auth (e.g., lock/key or security-related symbol) to improve visual differentiation and pipeline readability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/admin/dashboard/templates/execution-plan-chart.html` around lines 14
- 20, The Auth node currently reuses the same database SVG and styling as Cache
(ep-node-auth uses authNodeClass/authNodeSublabel); update the template and
styles so Auth has a distinct visual identity: replace the current cylinder SVG
in the ep-node-auth block with a security-themed SVG (lock/key) and assign a
distinct accent color and border/background variables (e.g., set --accent to a
different color) for the ep-node-auth class so it no longer matches Cache—ensure
you update any style rules that reference ep-node-auth or authNodeClass so the
new icon and color are applied.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/authkeys/store_postgresql.go`:
- Around line 46-48: Replace the inline ALTER TABLE Exec with the same
"migrations array" pattern used in auditlog: create a migrations []string
containing the ALTER TABLE statement (`ALTER TABLE auth_keys ADD COLUMN IF NOT
EXISTS user_path TEXT`) and then iterate over migrations calling pool.Exec(ctx,
migration) and returning a wrapped error (as currently done with fmt.Errorf) on
failure; this keeps consistency with the auditlog store and makes future
migrations clearer—locate the current pool.Exec call and replace it with the
migrations slice + loop, preserving use of pool.Exec, ctx, and fmt.Errorf for
errors.

In `@internal/authkeys/store_sqlite.go`:
- Around line 41-43: The inline ALTER TABLE call that adds user_path should be
converted to the same migrations-array pattern used in
internal/auditlog/store_sqlite.go and internal/usage/store_sqlite.go: create a
migrations []string (or []migration) entry containing "ALTER TABLE auth_keys ADD
COLUMN user_path TEXT", append it to the existing migrations list, and run it
through the module's migration executor so migrations are visible and auditable;
when applying this migration ensure you preserve the current error handling (use
isSQLiteDuplicateColumnError to ignore already-present column errors) so
behavior doesn't change.

---

Outside diff comments:
In `@internal/admin/dashboard/templates/execution-plan-chart.html`:
- Around line 14-20: The Auth node currently reuses the same database SVG and
styling as Cache (ep-node-auth uses authNodeClass/authNodeSublabel); update the
template and styles so Auth has a distinct visual identity: replace the current
cylinder SVG in the ep-node-auth block with a security-themed SVG (lock/key) and
assign a distinct accent color and border/background variables (e.g., set
--accent to a different color) for the ep-node-auth class so it no longer
matches Cache—ensure you update any style rules that reference ep-node-auth or
authNodeClass so the new icon and color are applied.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 943e5c87-538a-437d-bb84-99693e9f4e62

📥 Commits

Reviewing files that changed from the base of the PR and between ce6b390 and acd284e.

📒 Files selected for processing (27)
  • internal/admin/dashboard/static/css/dashboard.css
  • internal/admin/dashboard/static/js/modules/auth-keys.js
  • internal/admin/dashboard/static/js/modules/auth-keys.test.js
  • internal/admin/dashboard/static/js/modules/dashboard-layout.test.js
  • internal/admin/dashboard/static/js/modules/execution-plans-layout.test.js
  • internal/admin/dashboard/templates/execution-plan-chart.html
  • internal/admin/dashboard/templates/index.html
  • internal/admin/handler.go
  • internal/admin/handler_authkeys_test.go
  • internal/auditlog/auditlog_test.go
  • internal/auditlog/middleware.go
  • internal/authkeys/service.go
  • internal/authkeys/service_test.go
  • internal/authkeys/store.go
  • internal/authkeys/store_mongodb.go
  • internal/authkeys/store_postgresql.go
  • internal/authkeys/store_sqlite.go
  • internal/authkeys/types.go
  • internal/core/context.go
  • internal/core/request_snapshot.go
  • internal/core/request_snapshot_test.go
  • internal/core/user_path.go
  • internal/core/user_path_test.go
  • internal/server/auth.go
  • internal/server/auth_test.go
  • internal/server/http.go
  • internal/server/http_test.go

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/authkeys/store_postgresql.go`:
- Around line 163-176: The MongoDB store is missing the store-level
normalization present in PostgreSQL (pgNullableString/derefTrimmedString);
update the MongoDB write and read paths so they mirror PostgreSQL behavior by
trimming whitespace and converting empty strings to nil-equivalent values: in
the MongoDB Create method (where UserPath is assigned) apply a
trim-and-empty-to-nil normalization (same semantics as pgNullableString) before
persisting, and in authKeyFromMongo apply a trimmed-default behavior (same
semantics as derefTrimmedString) when constructing the returned auth key; this
keeps store-level behavior consistent with core.NormalizeUserPath and PostgreSQL
defenses.

In `@internal/authkeys/store_sqlite.go`:
- Around line 150-156: The error-detection is fragile because it relies on
message text; update the isSQLiteDuplicateColumnError function by adding a
concise comment documenting which SQLite library/driver and versions you tested
against and the exact error message variants observed (e.g., "duplicate column",
"already exists"), and expand the string checks to include likely variants such
as "duplicate column name" and patterns like "already exists:" to be more
robust; reference the function name isSQLiteDuplicateColumnError and ensure the
comment explains that these heuristics are used when a structured error code is
unavailable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9cd1961a-d58f-4e30-867c-1c8fb8d2df54

📥 Commits

Reviewing files that changed from the base of the PR and between acd284e and acf82f8.

📒 Files selected for processing (2)
  • internal/authkeys/store_postgresql.go
  • internal/authkeys/store_sqlite.go

Comment on lines +163 to +176
func pgNullableString(value string) any {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return value
}

func derefTrimmedString(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

❓ Verification inconclusive

Script executed:

#!/bin/bash
# Verify MongoDB store handling of UserPath
rg -n -A3 -B3 'UserPath' internal/authkeys/store_mongodb.go

Repository: ENTERPILOT/GoModel


Repository: ENTERPILOT/GoModel
Exit code: 0

stdout:

14-	ID            string     `bson:"_id"`
15-	Name          string     `bson:"name"`
16-	Description   string     `bson:"description,omitempty"`
17:	UserPath      string     `bson:"user_path,omitempty"`
18-	RedactedValue string     `bson:"redacted_value"`
19-	SecretHash    string     `bson:"secret_hash"`
20-	Enabled       bool       `bson:"enabled"`
--
79-		ID:            key.ID,
80-		Name:          key.Name,
81-		Description:   key.Description,
82:		UserPath:      key.UserPath,
83-		RedactedValue: key.RedactedValue,
84-		SecretHash:    key.SecretHash,
85-		Enabled:       key.Enabled,
--
124-		ID:            doc.ID,
125-		Name:          doc.Name,
126-		Description:   doc.Description,
127:		UserPath:      doc.UserPath,
128-		RedactedValue: doc.RedactedValue,
129-		SecretHash:    doc.SecretHash,
130-		Enabled:       doc.Enabled,

Script executed:

#!/bin/bash
# Check the PostgreSQL store to verify pgNullableString/derefTrimmedString usage
cat -n internal/authkeys/store_postgresql.go | head -200

Repository: ENTERPILOT/GoModel


Repository: ENTERPILOT/GoModel
Exit code: 0

stdout:

     1	package authkeys
     2	
     3	import (
     4		"context"
     5		"errors"
     6		"fmt"
     7		"strings"
     8		"time"
     9	
    10		"github.com/jackc/pgx/v5"
    11		"github.com/jackc/pgx/v5/pgxpool"
    12	)
    13	
    14	// PostgreSQLStore stores auth keys in PostgreSQL.
    15	type PostgreSQLStore struct {
    16		pool *pgxpool.Pool
    17	}
    18	
    19	// NewPostgreSQLStore creates the auth_keys table and indexes if needed.
    20	func NewPostgreSQLStore(ctx context.Context, pool *pgxpool.Pool) (*PostgreSQLStore, error) {
    21		if ctx == nil {
    22			return nil, fmt.Errorf("context is required")
    23		}
    24		if pool == nil {
    25			return nil, fmt.Errorf("connection pool is required")
    26		}
    27	
    28		_, err := pool.Exec(ctx, `
    29			CREATE TABLE IF NOT EXISTS auth_keys (
    30				id TEXT PRIMARY KEY,
    31				name TEXT NOT NULL,
    32				description TEXT NOT NULL DEFAULT '',
    33				user_path TEXT,
    34				redacted_value TEXT NOT NULL,
    35				secret_hash TEXT NOT NULL UNIQUE,
    36				enabled BOOLEAN NOT NULL DEFAULT TRUE,
    37				expires_at BIGINT,
    38				deactivated_at BIGINT,
    39				created_at BIGINT NOT NULL,
    40				updated_at BIGINT NOT NULL
    41			)
    42		`)
    43		if err != nil {
    44			return nil, fmt.Errorf("failed to create auth_keys table: %w", err)
    45		}
    46	
    47		migrations := []string{
    48			`ALTER TABLE auth_keys ADD COLUMN IF NOT EXISTS user_path TEXT`,
    49		}
    50		for _, migration := range migrations {
    51			if _, err := pool.Exec(ctx, migration); err != nil {
    52				return nil, fmt.Errorf("failed to run migration %q: %w", migration, err)
    53			}
    54		}
    55		for _, index := range []string{
    56			`CREATE INDEX IF NOT EXISTS idx_auth_keys_enabled ON auth_keys(enabled)`,
    57			`CREATE INDEX IF NOT EXISTS idx_auth_keys_created_at ON auth_keys(created_at DESC)`,
    58		} {
    59			if _, err := pool.Exec(ctx, index); err != nil {
    60				return nil, fmt.Errorf("failed to create auth_keys index: %w", err)
    61			}
    62		}
    63		return &PostgreSQLStore{pool: pool}, nil
    64	}
    65	
    66	func (s *PostgreSQLStore) List(ctx context.Context) ([]AuthKey, error) {
    67		rows, err := s.pool.Query(ctx, `
    68			SELECT id, name, description, user_path, redacted_value, secret_hash, enabled, expires_at, deactivated_at, created_at, updated_at
    69			FROM auth_keys
    70			ORDER BY created_at DESC, id ASC
    71		`)
    72		if err != nil {
    73			return nil, fmt.Errorf("list auth keys: %w", err)
    74		}
    75		defer rows.Close()
    76		result, err := collectAuthKeys(rows, scanPostgreSQLAuthKey)
    77		if err != nil {
    78			return nil, fmt.Errorf("iterate auth keys: %w", err)
    79		}
    80		return result, nil
    81	}
    82	
    83	func (s *PostgreSQLStore) Create(ctx context.Context, key AuthKey) error {
    84		_, err := s.pool.Exec(ctx, `
    85			INSERT INTO auth_keys (id, name, description, user_path, redacted_value, secret_hash, enabled, expires_at, deactivated_at, created_at, updated_at)
    86			VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
    87		`, key.ID, key.Name, key.Description, pgNullableString(key.UserPath), key.RedactedValue, key.SecretHash, key.Enabled, pgUnixOrNil(key.ExpiresAt), pgUnixOrNil(key.DeactivatedAt), key.CreatedAt.Unix(), key.UpdatedAt.Unix())
    88		if err != nil {
    89			return fmt.Errorf("create auth key: %w", err)
    90		}
    91		return nil
    92	}
    93	
    94	func (s *PostgreSQLStore) Deactivate(ctx context.Context, id string, now time.Time) error {
    95		cmd, err := s.pool.Exec(ctx, `
    96			UPDATE auth_keys
    97			SET enabled = FALSE,
    98				deactivated_at = COALESCE(deactivated_at, $1),
    99				updated_at = $2
   100			WHERE id = $3
   101		`, now.Unix(), now.Unix(), normalizeID(id))
   102		if err != nil {
   103			return fmt.Errorf("deactivate auth key: %w", err)
   104		}
   105		if cmd.RowsAffected() == 0 {
   106			return ErrNotFound
   107		}
   108		return nil
   109	}
   110	
   111	func (s *PostgreSQLStore) Close() error {
   112		return nil
   113	}
   114	
   115	func scanPostgreSQLAuthKey(scanner authKeyScanner) (AuthKey, error) {
   116		var key AuthKey
   117		var userPath *string
   118		var expiresAt *int64
   119		var deactivatedAt *int64
   120		var createdAt int64
   121		var updatedAt int64
   122		if err := scanner.Scan(
   123			&key.ID,
   124			&key.Name,
   125			&key.Description,
   126			&userPath,
   127			&key.RedactedValue,
   128			&key.SecretHash,
   129			&key.Enabled,
   130			&expiresAt,
   131			&deactivatedAt,
   132			&createdAt,
   133			&updatedAt,
   134		); err != nil {
   135			if errors.Is(err, pgx.ErrNoRows) {
   136				return AuthKey{}, ErrNotFound
   137			}
   138			return AuthKey{}, err
   139		}
   140		key.UserPath = derefTrimmedString(userPath)
   141		key.ExpiresAt = int64PtrToTime(expiresAt)
   142		key.DeactivatedAt = int64PtrToTime(deactivatedAt)
   143		key.CreatedAt = time.Unix(createdAt, 0).UTC()
   144		key.UpdatedAt = time.Unix(updatedAt, 0).UTC()
   145		return key, nil
   146	}
   147	
   148	func pgUnixOrNil(value *time.Time) any {
   149		if value == nil {
   150			return nil
   151		}
   152		return value.UTC().Unix()
   153	}
   154	
   155	func int64PtrToTime(value *int64) *time.Time {
   156		if value == nil {
   157			return nil
   158		}
   159		t := time.Unix(*value, 0).UTC()
   160		return &t
   161	}
   162	
   163	func pgNullableString(value string) any {
   164		value = strings.TrimSpace(value)
   165		if value == "" {
   166			return nil
   167		}
   168		return value
   169	}
   170	
   171	func derefTrimmedString(value *string) string {
   172		if value == nil {
   173			return ""
   174		}
   175		return strings.TrimSpace(*value)
   176	}

Script executed:

#!/bin/bash
# Get the full MongoDB store to see the actual structure and line numbers
cat -n internal/authkeys/store_mongodb.go | head -150

Repository: ENTERPILOT/GoModel


Repository: ENTERPILOT/GoModel
Exit code: 0

stdout:

     1	package authkeys
     2	
     3	import (
     4		"context"
     5		"fmt"
     6		"time"
     7	
     8		"go.mongodb.org/mongo-driver/v2/bson"
     9		"go.mongodb.org/mongo-driver/v2/mongo"
    10		"go.mongodb.org/mongo-driver/v2/mongo/options"
    11	)
    12	
    13	type mongoAuthKeyDocument struct {
    14		ID            string     `bson:"_id"`
    15		Name          string     `bson:"name"`
    16		Description   string     `bson:"description,omitempty"`
    17		UserPath      string     `bson:"user_path,omitempty"`
    18		RedactedValue string     `bson:"redacted_value"`
    19		SecretHash    string     `bson:"secret_hash"`
    20		Enabled       bool       `bson:"enabled"`
    21		ExpiresAt     *time.Time `bson:"expires_at,omitempty"`
    22		DeactivatedAt *time.Time `bson:"deactivated_at,omitempty"`
    23		CreatedAt     time.Time  `bson:"created_at"`
    24		UpdatedAt     time.Time  `bson:"updated_at"`
    25	}
    26	
    27	type mongoAuthKeyIDFilter struct {
    28		ID string `bson:"_id"`
    29	}
    30	
    31	// MongoDBStore stores auth keys in MongoDB.
    32	type MongoDBStore struct {
    33		collection *mongo.Collection
    34	}
    35	
    36	// NewMongoDBStore creates collection indexes if needed.
    37	func NewMongoDBStore(database *mongo.Database) (*MongoDBStore, error) {
    38		if database == nil {
    39			return nil, fmt.Errorf("database is required")
    40		}
    41		coll := database.Collection("auth_keys")
    42		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    43		defer cancel()
    44	
    45		indexes := []mongo.IndexModel{
    46			{Keys: bson.D{{Key: "secret_hash", Value: 1}}, Options: options.Index().SetUnique(true)},
    47			{Keys: bson.D{{Key: "enabled", Value: 1}}},
    48			{Keys: bson.D{{Key: "created_at", Value: -1}}},
    49		}
    50		if _, err := coll.Indexes().CreateMany(ctx, indexes); err != nil {
    51			return nil, fmt.Errorf("create auth_keys indexes: %w", err)
    52		}
    53		return &MongoDBStore{collection: coll}, nil
    54	}
    55	
    56	func (s *MongoDBStore) List(ctx context.Context) ([]AuthKey, error) {
    57		cursor, err := s.collection.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}, {Key: "_id", Value: 1}}))
    58		if err != nil {
    59			return nil, fmt.Errorf("list auth keys: %w", err)
    60		}
    61		defer cursor.Close(ctx)
    62	
    63		result := make([]AuthKey, 0)
    64		for cursor.Next(ctx) {
    65			var doc mongoAuthKeyDocument
    66			if err := cursor.Decode(&doc); err != nil {
    67				return nil, fmt.Errorf("decode auth key: %w", err)
    68			}
    69			result = append(result, authKeyFromMongo(doc))
    70		}
    71		if err := cursor.Err(); err != nil {
    72			return nil, fmt.Errorf("iterate auth keys: %w", err)
    73		}
    74		return result, nil
    75	}
    76	
    77	func (s *MongoDBStore) Create(ctx context.Context, key AuthKey) error {
    78		_, err := s.collection.InsertOne(ctx, mongoAuthKeyDocument{
    79			ID:            key.ID,
    80			Name:          key.Name,
    81			Description:   key.Description,
    82			UserPath:      key.UserPath,
    83			RedactedValue: key.RedactedValue,
    84			SecretHash:    key.SecretHash,
    85			Enabled:       key.Enabled,
    86			ExpiresAt:     key.ExpiresAt,
    87			DeactivatedAt: key.DeactivatedAt,
    88			CreatedAt:     key.CreatedAt.UTC(),
    89			UpdatedAt:     key.UpdatedAt.UTC(),
    90		})
    91		if err != nil {
    92			return fmt.Errorf("create auth key: %w", err)
    93		}
    94		return nil
    95	}
    96	
    97	func (s *MongoDBStore) Deactivate(ctx context.Context, id string, now time.Time) error {
    98		now = now.UTC()
    99		result, err := s.collection.UpdateOne(ctx, mongoAuthKeyIDFilter{ID: normalizeID(id)}, mongo.Pipeline{
   100			{{
   101				Key: "$set",
   102				Value: bson.D{
   103					{Key: "enabled", Value: false},
   104					{Key: "updated_at", Value: now},
   105					{Key: "deactivated_at", Value: bson.D{{Key: "$ifNull", Value: bson.A{"$deactivated_at", now}}}},
   106				},
   107			}},
   108		})
   109		if err != nil {
   110			return fmt.Errorf("deactivate auth key: %w", err)
   111		}
   112		if result.MatchedCount == 0 {
   113			return ErrNotFound
   114		}
   115		return nil
   116	}
   117	
   118	func (s *MongoDBStore) Close() error {
   119		return nil
   120	}
   121	
   122	func authKeyFromMongo(doc mongoAuthKeyDocument) AuthKey {
   123		return AuthKey{
   124			ID:            doc.ID,
   125			Name:          doc.Name,
   126			Description:   doc.Description,
   127			UserPath:      doc.UserPath,
   128			RedactedValue: doc.RedactedValue,
   129			SecretHash:    doc.SecretHash,
   130			Enabled:       doc.Enabled,
   131			ExpiresAt:     timePtrUTC(doc.ExpiresAt),
   132			DeactivatedAt: timePtrUTC(doc.DeactivatedAt),
   133			CreatedAt:     doc.CreatedAt.UTC(),
   134			UpdatedAt:     doc.UpdatedAt.UTC(),
   135		}
   136	}
   137	
   138	func timePtrUTC(value *time.Time) *time.Time {
   139		if value == nil {
   140			return nil
   141		}
   142		t := value.UTC()
   143		return &t
   144	}

Script executed:

#!/bin/bash
# Check service layer normalization of UserPath
rg -n 'NormalizeUserPath' --type go

Repository: ENTERPILOT/GoModel


Repository: ENTERPILOT/GoModel
Exit code: 0

stdout:

internal/authkeys/store.go:77:	userPath, err := core.NormalizeUserPath(input.UserPath)
internal/usage/user_path_filter.go:11:	userPath, err := core.NormalizeUserPath(raw)
internal/usage/stream_observer.go:28:		if normalized, err := core.NormalizeUserPath(userPath[0]); err == nil {
internal/server/request_snapshot.go:34:			userPath, err := core.NormalizeUserPath(req.Header.Get(core.UserPathHeader))
internal/executionplans/types.go:95:	userPath, err := core.NormalizeUserPath(scope.UserPath)
internal/core/execution_plan.go:85:		if normalized, err := NormalizeUserPath(userPath[0]); err == nil {
internal/core/user_path_test.go:8:func TestNormalizeUserPath(t *testing.T) {
internal/core/user_path_test.go:30:			got, err := NormalizeUserPath(tt.raw)
internal/core/user_path_test.go:33:					t.Fatal("NormalizeUserPath() error = nil, want error")
internal/core/user_path_test.go:38:				t.Fatalf("NormalizeUserPath() error = %v", err)
internal/core/user_path_test.go:41:				t.Fatalf("NormalizeUserPath() = %q, want %q", got, tt.want)
internal/core/user_path.go:11:// NormalizeUserPath canonicalizes one user hierarchy path from request ingress.
internal/core/user_path.go:12:func NormalizeUserPath(raw string) (string, error) {
internal/core/user_path.go:46:	path, err := NormalizeUserPath(path)
internal/admin/handler.go:174:	userPath, err := core.NormalizeUserPath(raw)
internal/auditlog/user_path_filter.go:11:	userPath, err := core.NormalizeUserPath(raw)

Script executed:

#!/bin/bash
# Check auth.go for trimming operations on read
cat -n internal/server/auth.go | sed -n '75,95p'

Repository: ENTERPILOT/GoModel


Repository: ENTERPILOT/GoModel
Exit code: 0

stdout:

    75				}
    76	
    77				if authenticator != nil && authenticator.Enabled() {
    78					auditlog.EnrichEntryWithAuthMethod(c, auditlog.AuthMethodAPIKey)
    79					authResult, err := authenticator.Authenticate(c.Request().Context(), token)
    80					if err == nil {
    81						ctx := core.WithAuthKeyID(c.Request().Context(), authResult.ID)
    82						if userPath := strings.TrimSpace(authResult.UserPath); userPath != "" {
    83							ctx = core.WithEffectiveUserPath(ctx, userPath)
    84							if snapshot := core.GetRequestSnapshot(ctx); snapshot != nil {
    85								ctx = core.WithRequestSnapshot(ctx, snapshot.WithUserPath(userPath))
    86							}
    87							c.Request().Header.Set(core.UserPathHeader, userPath)
    88							auditlog.EnrichEntryWithUserPath(c, userPath)
    89						}
    90						c.SetRequest(c.Request().WithContext(ctx))
    91						auditlog.EnrichEntryWithAuthKeyID(c, authResult.ID)
    92						return next(c)
    93					}
    94	
    95					authErr := authenticationErrorWithAudit(c, authFailureMessage(err), "authentication failed")

Store-level normalization inconsistency between PostgreSQL and MongoDB.

The PostgreSQL store applies pgNullableString during writes (converts empty/whitespace to null) and derefTrimmedString during reads (trims whitespace). MongoDB's Create method (line 82) and authKeyFromMongo (line 127) assign UserPath directly without any trimming or empty-to-null conversion.

While the service layer normalizes via core.NormalizeUserPath before calling Create, and the auth handler trims on read (auth.go:82), the store-level inconsistency creates a behavioral gap. PostgreSQL's defensive store-level normalization would handle edge cases like direct database manipulation or future code paths that bypass service normalization; MongoDB does not.

Consider either adding equivalent trimming to MongoDB's write/read paths or documenting that store-level normalization is optional and the service layer is the authoritative normalization point.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/authkeys/store_postgresql.go` around lines 163 - 176, The MongoDB
store is missing the store-level normalization present in PostgreSQL
(pgNullableString/derefTrimmedString); update the MongoDB write and read paths
so they mirror PostgreSQL behavior by trimming whitespace and converting empty
strings to nil-equivalent values: in the MongoDB Create method (where UserPath
is assigned) apply a trim-and-empty-to-nil normalization (same semantics as
pgNullableString) before persisting, and in authKeyFromMongo apply a
trimmed-default behavior (same semantics as derefTrimmedString) when
constructing the returned auth key; this keeps store-level behavior consistent
with core.NormalizeUserPath and PostgreSQL defenses.

Comment on lines +150 to +156
func isSQLiteDuplicateColumnError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "duplicate column") || strings.Contains(message, "already exists")
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Error detection logic is reasonable but could be fragile.

The isSQLiteDuplicateColumnError function checks for "duplicate column" or "already exists" substrings in lowercased error messages. This works for common SQLite versions but relies on error message text which could vary across SQLite versions or drivers.

Consider documenting the expected error messages or adding a comment noting which SQLite/driver versions this was tested against.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/authkeys/store_sqlite.go` around lines 150 - 156, The
error-detection is fragile because it relies on message text; update the
isSQLiteDuplicateColumnError function by adding a concise comment documenting
which SQLite library/driver and versions you tested against and the exact error
message variants observed (e.g., "duplicate column", "already exists"), and
expand the string checks to include likely variants such as "duplicate column
name" and patterns like "already exists:" to be more robust; reference the
function name isSQLiteDuplicateColumnError and ensure the comment explains that
these heuristics are used when a structured error code is unavailable.

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.

1 participant