`x in d` and `x in d.keys()` are identical for a plain dict, so the `.keys()` call builds a throwaway view and reads as if it were doing something. Both sites operate on a plain dict: `combined` in `merge_and_sort_query_results` is a local `dict()`, and `ui_settings` comes from `UserSettings.model_dump()` where `ui` is annotated `dict | None` and is already guarded against None on the preceding line.
No behaviour change, and no measurable speedup either, so this is a readability cleanup rather than a performance one.
Sites where `.keys()` is load-bearing are left alone: the `list(d.keys())` snapshots taken before mutating during iteration, and the places where `.keys()` is the iteration or comprehension source rather than a membership test.
Changing a password left every other logged-in device working until the JWT expired on its own, up to four weeks with the default settings. The hardening docs already promise the opposite: with Redis configured a password change is supposed to put the user's tokens on the revocation list, but only sign-out and OIDC back-channel logout ever wrote to it.
Both password-change paths, self-service and an admin resetting someone's password, now stamp the per-user revocation marker that token validation already checks, so every session issued before the change stops working. The acting device is signed out as well and asked to sign in again, which is the safer default when the password is being changed precisely because the old one may be compromised. Without Redis nothing can be revoked, as before, and the backend now logs a warning saying so.
The marker is written through one shared helper, so its lifetime follows the configured JWT lifetime instead of a fixed 30 days and never expires at all when JWT_EXPIRES_IN disables expiry. Back-channel logout picks that up too, where a long or disabled JWT lifetime previously let the marker expire while the tokens it revoked were still valid. API keys keep working, they are separate credentials with their own lifecycle.
Discussed in #28647.
Seven route handlers declare a request-scoped database session as a FastAPI dependency and then never touch it. Three of them are `GET /api/v1/users/user/settings`, `/user/status` and `/user/info`, which the frontend hits on every page load, and all three carry a comment saying the user object is already available, so the parameter is leftover from the refactor that removed the refetch. The other four are admin-only external-knowledge connection endpoints that read their data from the config store.
Measured on a route with and without the dependency, 20k requests, best of 5:
| | µs per request |
| --- | --- |
| no dependency | 16.18 |
| unused session dependency | 62.85 |
The dependency costs about three times as much as everything else the request does put together. It is worth being precise about why, because the obvious guess is wrong: this is not database I/O and not connection pool pressure. SQLAlchemy connects lazily, so a session that is never used checks out zero connections, verified by watching the pool's counter stay at zero across the request. The cost is FastAPI resolving an extra async-generator dependency onto the request's exit stack, plus constructing and closing the session object.
Deleting the seven parameters is the whole change. An AST scan over the backend finds exactly these seven handlers before and none after.
The SharingPermissions model defaulted public_tools and public_notes to
True while the config defaults (USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING
and USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING) are both False.
On an instance whose stored user.permissions config predates these keys,
GET /api/v1/users/default/permissions fills the gap from the model and
reports both as enabled, while has_permission fills it from
DEFAULT_USER_PERMISSIONS and denies. Saving any unrelated permission then
persists the model's True, granting public tool and note sharing the admin
never enabled.
AccessGrantsPermissions only declared allow_users, so allow_groups was
missing from the model backing the default user permissions endpoints.
Pydantic ignores undeclared fields, so POST /users/default/permissions
dropped allow_groups before model_dump(), it never reached the persisted
user.permissions config, and fill_missing_permissions restored it to the
default on the next read. Turning "Allow Sharing With Groups" off in
Admin Settings silently reverted to on, while the same toggle worked when
set per group, since group permissions are stored as a plain dict.
GET /users/default/permissions and /users/default/permissions/defaults
dropped it from their responses for the same reason.
Declare allow_groups on the model so it round-trips, matching the
access_grants block in DEFAULT_USER_PERMISSIONS. It defaults to True, so
existing payloads that omit it are unaffected.
`sharing.folders` is present in `DEFAULT_USER_PERMISSIONS` but absent from the `SharingPermissions` response/update schema, so the admin default and group permission API silently drops it on every round-trip and the setting is never saved. Add `folders: bool = False`, matching the config default (`USER_PERMISSIONS_FOLDERS_ALLOW_SHARING`), restoring parity with `DEFAULT_USER_PERMISSIONS`.
Fixes#27120.
Adds a 'Reset to Defaults' button to both the Edit Default Permissions
modal and the Edit User Group permissions modal.
- Default permissions modal: resets to stock/env-var configuration
- User group modal: resets to current global default permissions
Backend: new GET /api/v1/users/default/permissions/defaults endpoint
that returns DEFAULT_USER_PERMISSIONS (env-var-based initial defaults).
* fix: gate public sharing of calendars behind sharing.public_calendars permission
The calendar router did not call filter_allowed_access_grants on either the
create or update endpoint, while every other shareable resource in the
codebase (channels, knowledge, models, notes, prompts, skills, tools) does.
A verified non-admin owner could therefore attach
`{"principal_type":"user","principal_id":"*","permission":"read"|"write"}`
to their own calendar in the create or update payload and have it persisted
unfiltered. Any other verified user with the (default-on) features.calendar
permission could then read or, for write grants, write events on it via the
existing /events* endpoints, bypassing the per-user sharing.public_<X>
permission gate the rest of the resource cohort enforces.
Three changes:
- config.py: add USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING (default
False, env-overridable) and surface it in DEFAULT_USER_PERMISSIONS
['sharing']['public_calendars'] so admins can grant it per group via the
same UI used for notes/models/etc.
- routers/calendar.py: import filter_allowed_access_grants and call it in
create_calendar with the new sharing.public_calendars key, identical to
the channel router's pattern.
- routers/calendar.py: call filter_allowed_access_grants in update_calendar
too. The pre-existing owner-only gate at L350 only restricts WHO may
change grants; the new filter restricts WHICH grants they may set, so a
non-admin owner cannot make their own calendar publicly readable or
writable without the corresponding sharing permission.
Same shape as GHSA-7rjh-px4v-5w55 (channels). Reported by Matteo Panzeri.
Co-authored-by: Matteo Panzeri <28739806+matte1782@users.noreply.github.com>
* fix: expose public_calendars + features.calendar through admin permissions surface
The earlier commit added DEFAULT_USER_PERMISSIONS['sharing']['public_calendars']
and the runtime filter call, but the new key was not yet plumbed through the
admin /users/default/permissions endpoint. Without these changes the toggle
would round-trip as silently dropped:
- routers/users.py SharingPermissions: any payload POSTed to
/default/permissions ran through `form_data.model_dump()`, and Pydantic
drops fields not declared on the model. The new public_calendars key
would have been stripped on every save, leaving admins unable to grant
the permission via the UI even though the runtime filter would honor it.
- src/lib/constants/permissions.ts: the frontend's DEFAULT_PERMISSIONS dict
is the seed shape used by the admin Groups Permissions panel; without
the new key it could not bind a Switch component to it.
- Permissions.svelte: add a Calendars Public Sharing toggle alongside the
Notes/Chats Public Sharing toggles, gated on the existing
features.calendar flag (matches the pattern used for notes/chats).
Also closes a pre-existing parity gap on features.calendar: DEFAULT_USER_
PERMISSIONS['features']['calendar'] has existed since the calendar feature
shipped, and Permissions.svelte already renders a Calendar feature toggle,
but FeaturesPermissions Pydantic and the frontend defaults never knew
about it. Adding it everywhere completes the round-trip so admin saves no
longer silently drop the calendar feature flag either.
---------
Co-authored-by: Matteo Panzeri <28739806+matte1782@users.noreply.github.com>
* refac(routers): reject external URLs in profile/model image handlers
* refac(ui): centralize image URL validation in safeImageUrl helper
* refac(auths): make signout POST-only
* refac: gate external profile image redirect behind ENABLE_PROFILE_IMAGE_URL_FORWARDING
Restore the 302 redirect for external http(s) profile image URLs in
the user and model profile-image endpoints, but gate it behind a new
ENABLE_PROFILE_IMAGE_URL_FORWARDING env flag (default: True).
Existing deployments that rely on external profile image forwarding
continue to work unchanged. Operators who want to suppress the
redirect (to prevent client-side IP/UA/Referer leaks) can set the
flag to False.
* perf(users): drop redundant get_user_by_id refetch in session-user endpoints
Five /user/* handlers refetched the user row via Users.get_user_by_id(user.id)
immediately after receiving an identical UserModel from Depends(get_verified_user).
Since get_verified_user already populated the user within the same request
microseconds earlier, the refetch is pure overhead. The dead else branches
(unreachable — get_verified_user raises 401 on missing user) are removed as
a natural consequence.
Affected endpoints:
- GET /user/settings
- GET /user/status
- POST /user/status/update
- GET /user/info
- POST /user/info/update
Eliminates one SELECT per request to each of these endpoints with no behavioral
change.
* fix(users): preserve USER_NOT_FOUND error on status update failure
update_user_status_by_id returns None when the target user is missing or
the update raises. The previous commit removed the pre-update existence
gate (get_user_by_id) and returned the update result directly, which
turned not-found/failure cases into 200 OK with a null body instead of
the expected 400 USER_NOT_FOUND.
Guard the update result explicitly to preserve the original API contract,
matching the equivalent pattern already applied in /user/info/update.
* docs(users): note lost-update tradeoff on /user/info/update
Make the concurrency tradeoff explicit: merging against the auth-time
snapshot slightly widens the lost-update window compared to the previous
pre-merge refetch, but the refetch only narrowed (did not eliminate) that
window. Real safety requires row locking or a version column.
---------
Co-authored-by: Claude <noreply@anthropic.com>
SESSION_POOL caches user.role at connection time and never refreshes it. When an admin demotes or deletes a user, their socket sessions retain the old cached role until voluntary disconnect, allowing continued use of admin-gated socket features (ydoc editing, channel access).
Adds disconnect_user_sessions() helper that disconnects all sockets for a user ID. Called from update_user_by_id (on role change) and delete_user_by_id. The client auto-reconnects and re-authenticates with fresh DB data.