fix: gate public sharing of calendars behind sharing.public_calendars permission (#24493)
* 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>
This commit is contained in:
1 parent
69270e1c9e
commit
8a0018cf96
5 files changed
+55
-3
No files matched your search
@@ -1465,6 +1465,10 @@ USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING = (
|
|||||||
os.environ.get('USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
|
os.environ.get('USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = (
|
||||||
|
os.environ.get('USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING', 'False').lower() == 'true'
|
||||||
|
)
|
||||||
|
|
||||||
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
|
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
|
||||||
os.environ.get('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
|
os.environ.get('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
|
||||||
)
|
)
|
||||||
@@ -1585,6 +1589,7 @@ DEFAULT_USER_PERMISSIONS = {
|
|||||||
'notes': USER_PERMISSIONS_NOTES_ALLOW_SHARING,
|
'notes': USER_PERMISSIONS_NOTES_ALLOW_SHARING,
|
||||||
'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
|
'public_notes': USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
|
||||||
'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING,
|
'public_chats': USER_PERMISSIONS_CHAT_ALLOW_PUBLIC_SHARING,
|
||||||
|
'public_calendars': USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING,
|
||||||
},
|
},
|
||||||
'access_grants': {
|
'access_grants': {
|
||||||
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
|
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from open_webui.models.access_grants import AccessGrants
|
|||||||
from open_webui.models.groups import Groups
|
from open_webui.models.groups import Groups
|
||||||
from open_webui.models.users import UserModel
|
from open_webui.models.users import UserModel
|
||||||
from open_webui.utils.auth import get_verified_user
|
from open_webui.utils.auth import get_verified_user
|
||||||
from open_webui.utils.access_control import has_permission
|
from open_webui.utils.access_control import has_permission, filter_allowed_access_grants
|
||||||
from open_webui.utils.calendar import expand_recurring_event
|
from open_webui.utils.calendar import expand_recurring_event
|
||||||
from open_webui.constants import ERROR_MESSAGES
|
from open_webui.constants import ERROR_MESSAGES
|
||||||
|
|
||||||
@@ -112,6 +112,17 @@ async def get_calendars(request: Request, user: UserModel = Depends(get_verified
|
|||||||
async def create_calendar(request: Request, form_data: CalendarForm, user: UserModel = Depends(get_verified_user)):
|
async def create_calendar(request: Request, form_data: CalendarForm, user: UserModel = Depends(get_verified_user)):
|
||||||
"""Create a new user calendar."""
|
"""Create a new user calendar."""
|
||||||
await check_calendar_permission(request, user)
|
await check_calendar_permission(request, user)
|
||||||
|
# Strip public/user grants the requesting user is not permitted to assign
|
||||||
|
# (matches the channel/notes/models pattern). Without this, any verified user
|
||||||
|
# could create a calendar with `principal_id='*' permission='read'|'write'`,
|
||||||
|
# making their events readable or writable by any other verified user.
|
||||||
|
form_data.access_grants = await filter_allowed_access_grants(
|
||||||
|
request.app.state.config.USER_PERMISSIONS,
|
||||||
|
user.id,
|
||||||
|
user.role,
|
||||||
|
form_data.access_grants,
|
||||||
|
'sharing.public_calendars',
|
||||||
|
)
|
||||||
return await Calendars.insert_new_calendar(user.id, form_data)
|
return await Calendars.insert_new_calendar(user.id, form_data)
|
||||||
|
|
||||||
|
|
||||||
@@ -350,6 +361,20 @@ async def update_calendar(
|
|||||||
if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin':
|
if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin':
|
||||||
raise HTTPException(status_code=403, detail='Only owner can manage sharing')
|
raise HTTPException(status_code=403, detail='Only owner can manage sharing')
|
||||||
|
|
||||||
|
# Strip public/user grants the requesting user is not permitted to assign
|
||||||
|
# (matches the channel/notes/models pattern). The owner-only check above
|
||||||
|
# only restricts WHO can set grants; this filter restricts WHICH grants
|
||||||
|
# they may set, so a non-admin owner cannot make their calendar
|
||||||
|
# publicly readable/writable without the corresponding sharing permission.
|
||||||
|
if form_data.access_grants is not None:
|
||||||
|
form_data.access_grants = await filter_allowed_access_grants(
|
||||||
|
request.app.state.config.USER_PERMISSIONS,
|
||||||
|
user.id,
|
||||||
|
user.role,
|
||||||
|
form_data.access_grants,
|
||||||
|
'sharing.public_calendars',
|
||||||
|
)
|
||||||
|
|
||||||
updated = await Calendars.update_calendar_by_id(calendar_id, form_data)
|
updated = await Calendars.update_calendar_by_id(calendar_id, form_data)
|
||||||
if not updated:
|
if not updated:
|
||||||
raise HTTPException(status_code=500, detail='Failed to update')
|
raise HTTPException(status_code=500, detail='Failed to update')
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ class SharingPermissions(BaseModel):
|
|||||||
notes: bool = False
|
notes: bool = False
|
||||||
public_notes: bool = True
|
public_notes: bool = True
|
||||||
public_chats: bool = False
|
public_chats: bool = False
|
||||||
|
public_calendars: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AccessGrantsPermissions(BaseModel):
|
class AccessGrantsPermissions(BaseModel):
|
||||||
@@ -235,6 +236,7 @@ class FeaturesPermissions(BaseModel):
|
|||||||
code_interpreter: bool = True
|
code_interpreter: bool = True
|
||||||
memories: bool = True
|
memories: bool = True
|
||||||
automations: bool = False
|
automations: bool = False
|
||||||
|
calendar: bool = True
|
||||||
|
|
||||||
|
|
||||||
class SettingsPermissions(BaseModel):
|
class SettingsPermissions(BaseModel):
|
||||||
|
|||||||
@@ -410,6 +410,24 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if permissions.features.calendar}
|
||||||
|
<div class="flex flex-col w-full">
|
||||||
|
<div class="flex w-full justify-between my-1">
|
||||||
|
<div class=" self-center text-xs font-medium">
|
||||||
|
{$i18n.t('Calendars Public Sharing')}
|
||||||
|
</div>
|
||||||
|
<Switch bind:state={permissions.sharing.public_calendars} />
|
||||||
|
</div>
|
||||||
|
{#if defaultPermissions?.sharing?.public_calendars && !permissions.sharing.public_calendars}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
{$i18n.t('This is a default user permission and will remain enabled.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
|
<hr class=" border-gray-100/30 dark:border-gray-850/30" />
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export const DEFAULT_PERMISSIONS = {
|
|||||||
public_skills: false,
|
public_skills: false,
|
||||||
notes: false,
|
notes: false,
|
||||||
public_notes: false,
|
public_notes: false,
|
||||||
public_chats: false
|
public_chats: false,
|
||||||
|
public_calendars: false
|
||||||
},
|
},
|
||||||
access_grants: {
|
access_grants: {
|
||||||
allow_users: true
|
allow_users: true
|
||||||
@@ -62,7 +63,8 @@ export const DEFAULT_PERMISSIONS = {
|
|||||||
image_generation: true,
|
image_generation: true,
|
||||||
code_interpreter: true,
|
code_interpreter: true,
|
||||||
memories: true,
|
memories: true,
|
||||||
automations: false
|
automations: false,
|
||||||
|
calendar: true
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
interface: true
|
interface: true
|
||||||
|
|||||||
Reference in new issue
Block a user