feat(aliases): add user_path restrictions to model aliases#387
feat(aliases): add user_path restrictions to model aliases#387vfeitoza wants to merge 3 commits into
Conversation
Aliases can now be restricted to specific user paths. When a `user_paths` list is set on an alias, only requests whose `X-GoModel-User-Path` header matches one of the listed paths (or a descendant) will have the alias applied. Requests that do not match fall through to the original model name unchanged. - An empty `user_paths` list (the default) applies the alias to all users - A single "/" entry also applies the alias to all users - Paths are matched exactly or by prefix (e.g. /team matches /team/alpha) - Multiple paths can be specified per alias Backend changes: - `Alias.UserPaths []string` field added with `MatchesUserPath()` helper - `Service.ResolveModelWithUserPath()` reads user_path from context and filters aliases that do not match the caller's path - All three stores (SQLite, PostgreSQL, MongoDB) persist the new field; SQLite and PostgreSQL use a JSON column with a safe migration default - `ModelResolver` interface updated to accept `context.Context` so the resolution chain can carry request-scoped user_path through to alias evaluation without changing public API signatures - Admin handler accepts `user_paths` in the upsert request body - `View.HasUserPathRestriction` flag exposed for dashboard display Dashboard changes: - Alias create/edit form includes a "User Paths" textarea (one path per line, reuses the same normalizer as model overrides) - Existing alias data is populated in the form when editing - Toggle and save operations preserve the current user_paths value
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThis PR adds user-path-aware alias resolution and threads request context through model resolution. Aliases gain per-alias UserPaths and MatchesUserPath logic; admin UI and handler support editing user_paths; MongoDB/PostgreSQL/SQLite persist user_paths; resolver interfaces and provider/gateway/batch code are updated to accept and pass context for user-path–scoped resolution. ChangesUser-path alias resolution
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR adds user-path restrictions for model aliases. The main changes are:
Confidence Score: 2/5These issues should be fixed before merging.
Focus on alias store migrations, alias path normalization, and the restricted-alias match check.
|
| Filename | Overview |
|---|---|
| internal/aliases/service.go | Adds user-path-aware alias resolution but skips restrictions when no user path is present. |
| internal/aliases/store_sqlite.go | Adds the user_paths column to the schema and queries without migrating existing alias tables. |
| internal/aliases/store_postgresql.go | Adds JSONB persistence for alias user paths without migrating existing alias tables. |
| internal/aliases/store.go | Normalizes alias core fields but leaves UserPaths raw. |
| internal/aliases/types.go | Adds path matching logic that depends on already-canonical stored paths. |
Comments Outside Diff (2)
-
internal/aliases/store_sqlite.go, line 23-34 (link)Existing SQLite installs already have an
aliasestable, so thisCREATE TABLE IF NOT EXISTSblock will not adduser_paths. The changedSELECTandINSERTstatements now reference that column, so alias listing and saving can fail after upgrade with a missing-column error. Add an idempotentALTER TABLE aliases ADD COLUMN user_paths TEXT NOT NULL DEFAULT '[]'migration for existing tables. -
internal/aliases/store_postgresql.go, line 28-39 (link)Existing PostgreSQL installs already have an
aliasestable, so thisCREATE TABLE IF NOT EXISTSstatement will not adduser_paths. The changed reads and upserts now select and write that column, which breaks the aliases store after upgrade for any database created before this PR. Add anALTER TABLE aliases ADD COLUMN IF NOT EXISTS user_paths JSONB NOT NULL DEFAULT '[]'::jsonbmigration before the queries can use it.
Reviews (1): Last reviewed commit: "feat(aliases): add user_path restriction..." | Re-trigger Greptile
| // Check user_path restriction if userPath is provided | ||
| if userPath != "" && !alias.MatchesUserPath(userPath) { | ||
| return resolution, false |
There was a problem hiding this comment.
Empty path bypasses restriction
This only checks MatchesUserPath when userPath is non-empty. If a request has no effective user path in the context, a restricted alias such as UserPaths: []string{"/team"} still resolves because this branch is skipped. That means callers can omit X-GoModel-User-Path and still use an alias that should only apply to matching paths.
| // Check if userPath is a descendant of allowed path | ||
| if strings.HasPrefix(userPath, allowed+"/") { | ||
| return true |
There was a problem hiding this comment.
Trailing slash breaks descendants
The prefix check appends "/" to the stored value. If an alias is saved with user_paths: ["/team/"], a request for /team/alpha is checked against the prefix /team// and does not match. This makes a common path input reject its intended descendants unless paths are canonicalized before matching.
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/aliases/store_postgresql.go (1)
28-39:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftSQL schema evolution root cause: new
user_pathsis queried without guaranteed additive migration on existing databases.
Both stores define the column only inCREATE TABLE IF NOT EXISTS, which does not update already-created tables. Add explicit additive migration (ALTER TABLE ... ADD COLUMN ...with safe defaults/guards) before any query path that selectsuser_paths.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/aliases/store_postgresql.go` around lines 28 - 39, The aliases table creation only uses CREATE TABLE IF NOT EXISTS so existing DBs may lack the new user_paths column; add an explicit additive migration before any code that reads user_paths by executing an ALTER TABLE on the aliases table (e.g. via pool.Exec) that does ADD COLUMN IF NOT EXISTS user_paths JSONB NOT NULL DEFAULT '[]' (with the same type/default used in the CREATE block) and run this migration in the same init/migration path that currently runs the CREATE TABLE (the function that executes the shown pool.Exec), ensuring the ALTER runs before any queries/selects of user_paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/aliases/service.go`:
- Line 97: The HasUserPathRestriction boolean is computed incorrectly: it treats
only the single-entry ["/"] as allow-all, but runtime matching treats any "/"
entry as allow-all. Change the logic that sets HasUserPathRestriction in
internal/aliases/service.go (the expression using alias.UserPaths and
HasUserPathRestriction) to consider the presence of "/" anywhere in
alias.UserPaths as meaning "no restriction" (i.e., HasUserPathRestriction should
be false if any entry == "/"); implement this by checking for existence of "/"
in alias.UserPaths instead of the current len(...) == 1 && alias.UserPaths[0] ==
"/".
- Around line 304-307: The guard that only enforces alias.MatchesUserPath when
userPath != "" allows restricted aliases to be applied when no userPath is
present; change the logic in internal/aliases/service.go so MatchesUserPath is
always evaluated (remove the userPath != "" condition) and return (resolution,
false) whenever alias.MatchesUserPath(userPath) is false, ensuring callers fall
back to the requested/original model; locate the check around the alias handling
(the alias.MatchesUserPath call) and update it accordingly.
In `@internal/aliases/store_postgresql.go`:
- Around line 147-149: The JSON decode of user_paths currently swallows errors
and sets alias.UserPaths = nil, which fails-open; change the scanner in
internal/aliases/store_postgresql.go so that when json.Unmarshal(userPathsJSON,
&alias.UserPaths) returns an error you fail-closed: do NOT set UserPaths to nil
— instead return the error from the scanner (or mark the alias as
invalid/unresolvable) so the caller rejects the row. Update the function that
performs this scan (the code block handling alias.UserPaths / json.Unmarshal) to
propagate the decode error rather than silently clearing the restriction.
---
Outside diff comments:
In `@internal/aliases/store_postgresql.go`:
- Around line 28-39: The aliases table creation only uses CREATE TABLE IF NOT
EXISTS so existing DBs may lack the new user_paths column; add an explicit
additive migration before any code that reads user_paths by executing an ALTER
TABLE on the aliases table (e.g. via pool.Exec) that does ADD COLUMN IF NOT
EXISTS user_paths JSONB NOT NULL DEFAULT '[]' (with the same type/default used
in the CREATE block) and run this migration in the same init/migration path that
currently runs the CREATE TABLE (the function that executes the shown
pool.Exec), ensuring the ALTER runs before any queries/selects of user_paths.
🪄 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: 567a4c50-6809-4557-9f64-dcc9accc62a5
📒 Files selected for processing (20)
internal/admin/dashboard/static/js/modules/aliases.jsinternal/admin/dashboard/templates/page-models.htmlinternal/admin/handler_aliases.gointernal/admin/handler_usage.gointernal/aliases/batch_preparer.gointernal/aliases/provider.gointernal/aliases/provider_test.gointernal/aliases/service.gointernal/aliases/service_test.gointernal/aliases/store_mongodb.gointernal/aliases/store_postgresql.gointernal/aliases/store_sqlite.gointernal/aliases/types.gointernal/gateway/batch_selection.gointernal/gateway/interfaces.gointernal/gateway/request_model_resolution.gointernal/gateway/request_model_resolution_test.gointernal/modeloverrides/batch_preparer.gointernal/providers/router.gointernal/server/audio_service.go
| view.ProviderType = strings.TrimSpace(s.catalog.GetProviderType(view.ResolvedModel)) | ||
| view.Valid = s.catalog.Supports(view.ResolvedModel) | ||
| } | ||
| view.HasUserPathRestriction = len(alias.UserPaths) > 0 && !(len(alias.UserPaths) == 1 && alias.UserPaths[0] == "/") |
There was a problem hiding this comment.
HasUserPathRestriction is miscomputed when / appears with other entries.
Line 97 marks ["/", "/team"] as restricted, but runtime matching treats any / entry as allow-all. This makes dashboard state inconsistent with resolver behavior.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/aliases/service.go` at line 97, The HasUserPathRestriction boolean
is computed incorrectly: it treats only the single-entry ["/"] as allow-all, but
runtime matching treats any "/" entry as allow-all. Change the logic that sets
HasUserPathRestriction in internal/aliases/service.go (the expression using
alias.UserPaths and HasUserPathRestriction) to consider the presence of "/"
anywhere in alias.UserPaths as meaning "no restriction" (i.e.,
HasUserPathRestriction should be false if any entry == "/"); implement this by
checking for existence of "/" in alias.UserPaths instead of the current len(...)
== 1 && alias.UserPaths[0] == "/".
| // Check user_path restriction if userPath is provided | ||
| if userPath != "" && !alias.MatchesUserPath(userPath) { | ||
| return resolution, false | ||
| } |
There was a problem hiding this comment.
Always enforce MatchesUserPath; current guard bypasses restrictions.
Line 305 only checks restrictions when userPath != "". For requests without an effective user path, restricted aliases are still applied instead of falling back to the original model.
Suggested fix
- if userPath != "" && !alias.MatchesUserPath(userPath) {
+ if !alias.MatchesUserPath(userPath) {
return resolution, false
}Based on PR objectives, non-matching requests must fall back to the requested/original model.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Check user_path restriction if userPath is provided | |
| if userPath != "" && !alias.MatchesUserPath(userPath) { | |
| return resolution, false | |
| } | |
| // Check user_path restriction if userPath is provided | |
| if !alias.MatchesUserPath(userPath) { | |
| return resolution, false | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/aliases/service.go` around lines 304 - 307, The guard that only
enforces alias.MatchesUserPath when userPath != "" allows restricted aliases to
be applied when no userPath is present; change the logic in
internal/aliases/service.go so MatchesUserPath is always evaluated (remove the
userPath != "" condition) and return (resolution, false) whenever
alias.MatchesUserPath(userPath) is false, ensuring callers fall back to the
requested/original model; locate the check around the alias handling (the
alias.MatchesUserPath call) and update it accordingly.
| if err := json.Unmarshal(userPathsJSON,&alias.UserPaths); err != nil { | ||
| alias.UserPaths = nil | ||
| } |
There was a problem hiding this comment.
Access-control root cause: malformed user_paths JSON is handled fail-open in both SQL scanners.
On unmarshal failure, both paths set UserPaths to nil, which broadens access to allow-all. Decode failures should fail closed (e.g., return error, or treat alias as invalid/unresolvable) rather than silently removing restrictions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/aliases/store_postgresql.go` around lines 147 - 149, The JSON decode
of user_paths currently swallows errors and sets alias.UserPaths = nil, which
fails-open; change the scanner in internal/aliases/store_postgresql.go so that
when json.Unmarshal(userPathsJSON, &alias.UserPaths) returns an error you
fail-closed: do NOT set UserPaths to nil — instead return the error from the
scanner (or mark the alias as invalid/unresolvable) so the caller rejects the
row. Update the function that performs this scan (the code block handling
alias.UserPaths / json.Unmarshal) to propagate the decode error rather than
silently clearing the restriction.
Update 4 mock ResolveModel implementations in internal/server/ to accept context.Context as the first parameter, matching the updated ModelResolver interface. Update aliases.test.cjs to include user_paths in the alias fixture and expected request bodies for toggleAliasEnabled and submitAliasForm, which now always include the field.
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Rewrite the boolean expression to satisfy the staticcheck QF1001 rule.
|
Thank you for your work! The PR inspired me to unify model overrides with aliases into a virtual model entity. The underlying issue and the reason why this PR is needed, is that we treat aliases as something different from model overrides. Unifying those two entities into one is future-proof and helpful for maintenance. It also lets us simplify the UI a little bit. Your work here was really helpful in detecting this underlying issue, but "good is the enemy of better". Thank you again and the PR is coming! |
* feat(virtualmodels)!: unify engine, admin API, and dashboard UI Finish the virtual-models unification begun in #416. Collapse the two composed matching engines (aliases + model access overrides) into one native engine over the single virtual_models table, behind one in-memory snapshot, and wire a single admin endpoint and dashboard surface on top. Backend - Native engine: redirect (alias) resolution and policy (access) gating now run on VirtualModel rows directly, one atomically-swapped snapshot, one refresh loop. The internal/aliases and internal/modeloverrides packages are removed; their tested matching logic was ported. Legacy tables stay one release as a rollback net, read only by a self-contained seed. - Authoritative Enabled: a policy row's Enabled governs access (disabled = off for everyone; enabled + user_paths = restricted; no row = MODELS_ENABLED_BY_DEFAULT). A single model can now be disabled. - Scoped redirects: user_paths on a redirect are enforced at resolution via the optional gateway.UserPathModelResolver, so an alias applies only to matching callers and otherwise falls through to the literal name (the use case from the closed upstream PR #387). Admin API - One GET/PUT/DELETE /admin/virtual-models endpoint replaces /admin/aliases and /admin/model-overrides. Dashboard - One virtual-model editor (Source locked + prefilled when editing an existing model, an always-present target field, user_paths, a 3-state Enabled/Restricted/Disabled switch, description), a per-row enable/disable toggle on every model, and alias-like styling for any model carrying a virtual model. The pricing and virtual-model editors share one layout. Cleanups - Pre-parse redirect targets in the snapshot (no per-request ParseModelSelector on the hot path); validate redirect user_paths instead of silently dropping them; DRY the source lookup; tighten compile-time interface assertions. See docs/adr/0008-virtual-models.md (updated). BREAKING CHANGE: the /admin/aliases and /admin/model-overrides admin endpoints are removed in favor of /admin/virtual-models; the internal aliases and modeloverrides packages are deleted. Runtime model-routing behavior is preserved; access semantics gain authoritative per-row Enabled and scoped redirects. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(virtualmodels): address review — refresh cadence, seed safety, scoped alias listing Addresses the greptile review on #423. - Refresh cadence (P1): the unified refresh loop used only the model-cache interval (default 1h), so on a multi-instance deployment a disable/restrict on one instance could take up to an hour to reach others — a regression from the policy side's prior minute cadence. Use the shorter of the model-cache and workflow intervals. - Seed safety (P1): the seed wrote legacy aliases before checking access-override source collisions, so a conflict could abort mid-write and leave virtual_models partially seeded; the len(existing) > 0 guard would then skip the seed on the next startup and never import the access overrides. Resolve all rows and detect collisions before writing anything (test now asserts an empty table after a conflict). - Scoped alias listing (P2): ExposedModelsFiltered only filtered by the target policy, so a redirect scoped to user_paths was listed in /v1/models to callers outside its scope even though resolution falls through for them. Add ExposedModelsForUserPath (hiding redirects the caller does not match) and have the server prefer it via the optional UserPathExposedModelLister. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(virtualmodels,dashboard): address coderabbit review Backend - Make request-time redirect rewrites user-path aware (Major, security): the shared Provider/BatchPreparer rewrite path resolved redirects with unscoped ResolveModel, so a user_paths-scoped alias could be applied for callers outside its scope in batch and guardrail-executor requests (the main chat path already resolved scoped via the gateway). Thread ctx through the rewrite helpers and use ResolveModelForUserPath for request-time rewrites; keep unscoped ResolveModel only for inventory/projection. - Seed test now asserts migrated Enabled semantics, including a disabled legacy alias staying disabled (Enabled is authoritative). Dashboard - vmFormEffectiveEnabled now reflects the override's value (a disabled override shows "Effective now: no" even when the default is enabled), not its presence. - toggleRowEnabled bails out when virtual models are unavailable, avoiding repeated failing writes after a 503. - Create-mode overwrite confirmation runs even with an empty target, since that is a policy upsert that can otherwise silently replace an existing redirect/policy. - Save is disabled while a Remove is in progress (vmDeleting), preventing concurrent DELETE + PUT. Not changed: handler_usage.go pricing recalculation resolves an operator-supplied selector that has no request user_path, and v1 redirects have a single target, so unscoped resolution yields the correct concrete model for pricing — using the user-path resolver there would wrongly fall through to the literal name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(virtualmodels,server): address review — exposure scoping, enable toggle, seed rollback - /v1/models exposure scoping no longer requires a model authorizer (server, Major): UserPathExposedModelLister now runs even when h.modelAuthorizer is nil (passing a nil target-allow), so a user_paths-scoped redirect is still hidden from callers outside its scope instead of falling back to unscoped listing. - Dashboard enable toggle (P1): enabling a model only DELETEs a path-less policy when the default actually enables it (default_enabled != false). In a default-disabled deployment it now PUTs enabled:true so the model is really enabled instead of falling back to the disabled default while the UI claims "enabled". - Seed partial-write rollback (P1): if a write fails mid-seed (DB error, context cancellation), already-written rows are rolled back (best-effort, fresh context) so a partial seed cannot trip the len(existing) > 0 guard and skip importing the remaining access controls next startup. Test added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dashboard): unify enable/restrict/disable toggle across all access scopes Fixes the edit-form prefill for provider-wide ("{provider}/") and any policy whose effective state was computed from override PRESENCE rather than its enabled VALUE: providerGroupAccess.effective_enabled now honors the override's enabled flag (a disabled provider policy reads as disabled), and the model/provider editor reads the override's own enabled value. This also corrects the provider-group state. Adds the same three-state switch (Enabled / Restricted / Disabled) to the provider ("{provider}/") and global ("/") rows on the models list, matching model and alias rows. The toggle markup is extracted into one reusable {{template "access-toggle"}} partial driven by the existing scope-agnostic rowToggle*/toggleRowEnabled helpers, so alias rows, model rows, provider groups, and a new globalScopeRow all share it. The redundant model-access-state-badge (and now-dead modelAccessStateText) are removed — the toggle is the single state indicator everywhere. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(virtualmodels): scope provider ListModels + atomic SQL seed Addresses the latest greptile re-review. - Provider.ListModels merged exposed redirects with the unscoped ExposedModels() projection, leaking user_paths-scoped redirect IDs to callers outside their scope (the server /v1/models path was already scoped). It has ctx, so use ExposedModelsForUserPath(core.UserPathFromContext(ctx), nil) for parity. - The legacy seed now writes atomically on the SQL backends: SQLiteStore and PostgreSQLStore implement an optional transactional UpsertAll, and the seed prefers it so a failed seed leaves the table untouched rather than partially populated (which would trip the "already populated" guard and suppress a re-import next start). MongoDB — which has no cross-backend transaction without a replica set — keeps the per-row best-effort rollback fallback. Added an UpsertAll atomicity test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(dashboard,virtualmodels): unify refresh cadence, rename module, collapse flags - Refresh cadence: the unified store now refreshes solely on Cache.Model.Refresh- Interval (virtual models are part of the model-config plane, same cadence as the provider model list), dropping the Workflows.RefreshInterval min() mixing. - Rename the dashboard controller aliases.js -> virtual-models.js (and dashboardAliasesModule -> dashboardVirtualModelsModule, aliases.test.cjs -> virtual-models.test.cjs); update the script loader, embedded-asset test, and template reference. The module drives the unified virtual-models UI, so the "aliases" name was a misnomer. - Collapse the redundant aliasesAvailable/modelOverridesAvailable flags into the single virtualModelsAvailable (templates already used it). - Remove the now-dead model-access-state-badge CSS (the toggle replaced the badge at every scope). Inert load-balancing fields (Strategy, Target.Weight, multi-target) are kept per ADR-0008. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(virtualmodels): fresh rollback ctx for Postgres seed tx + mid-batch atomicity test Addresses coderabbit review on the UpsertAll work: - PostgreSQLStore.UpsertAll deferred its tx.Rollback with the seed context, so a canceled context would skip rollback and leak cleanup to pgx. Use a fresh rollbackContext() (mirrors service.go). SQLite's tx.Rollback() takes no context and is unaffected. - TestSQLiteStore_UpsertAllIsAtomic previously failed at BeginTx (pre-write), so it never proved rollback after a successful row. Replace it with a true mid-batch failure (row 1 written in the tx, row 2 fails to encode) and assert the table is left empty. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Aliases can now be restricted to specific user paths. When a
user_pathslist is set on an alias, only requests whoseX-GoModel-User-Pathheader matches one of the listed paths (or a descendant) will have the alias applied. Requests that do not match fall through to the original model name unchanged.user_pathslist (the default) applies the alias to all usersBackend changes:
Alias.UserPaths []stringfield added withMatchesUserPath()helperService.ResolveModelWithUserPath()reads user_path from context and filters aliases that do not match the caller's pathModelResolverinterface updated to acceptcontext.Contextso the resolution chain can carry request-scoped user_path through to alias evaluation without changing public API signaturesuser_pathsin the upsert request bodyView.HasUserPathRestrictionflag exposed for dashboard displayDashboard changes:
Summary by CodeRabbit