diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c678f7c..239f0800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] ### Fixed +- **Behaviour change:** evict expired site-lock attempt buckets, paginate personal/admin API token lists + (default and maximum API page size 100, with `limit`/`offset`), preserve revoke-all + across pages, and recover concurrent duplicate share grants without a 500 (#640). +- Share UTC timestamp helpers across sharing, tenants, background tasks, API tokens + and users (#640). - Users: publish deletion, disabling and new role assignments transactionally through the durable outbox (inline cleanup without background tasks). Deletion purges tenant memberships, shares to the user and direct permission grants; tenants left without @@ -61,6 +66,8 @@ All notable changes to this project are documented in this file. The format is b session (#615). ### Security +- Remove account-status and superuser fields from the users `UserUpdate` contract; + self-profile routes already used a narrower input schema (#640). - Inertia: `Inertia.back()` and the exported validation handler now redirect only to a same-site path taken from `Referer`, falling back to `/`; an off-site or malformed `Referer` previously produced an open redirect (#635). diff --git a/framework/core/simple_module_core/datetimes.py b/framework/core/simple_module_core/datetimes.py new file mode 100644 index 00000000..ab011034 --- /dev/null +++ b/framework/core/simple_module_core/datetimes.py @@ -0,0 +1,12 @@ +"""UTC timestamps and normalization of legacy naive database values.""" + +from datetime import UTC, datetime + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +def aware(value: datetime) -> datetime: + """Interpret a naive value as UTC; preserve an existing timezone.""" + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) diff --git a/framework/core/tests/test_core_datetimes.py b/framework/core/tests/test_core_datetimes.py new file mode 100644 index 00000000..09f7b38a --- /dev/null +++ b/framework/core/tests/test_core_datetimes.py @@ -0,0 +1,19 @@ +"""Shared UTC helpers preserve aware values and interpret naive values as UTC.""" + +from datetime import UTC, datetime, timedelta, timezone + +from simple_module_core.datetimes import aware, utcnow + + +def test_naive_is_utc_and_aware_keeps_its_offset(): + naive = datetime(2026, 1, 1) + assert aware(naive) == datetime(2026, 1, 1, tzinfo=UTC) + offset = naive.replace(tzinfo=timezone(timedelta(hours=2))) + assert aware(offset) is offset + + +def test_utcnow_is_current_aware_utc(): + before = datetime.now(UTC) + value = utcnow() + assert before <= value <= datetime.now(UTC) + assert value.tzinfo is UTC diff --git a/modules/api_tokens/api_tokens/endpoints/admin_api.py b/modules/api_tokens/api_tokens/endpoints/admin_api.py index 014cebb8..8dab7205 100644 --- a/modules/api_tokens/api_tokens/endpoints/admin_api.py +++ b/modules/api_tokens/api_tokens/endpoints/admin_api.py @@ -9,7 +9,7 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from simple_module_db import TenantIsolationError from simple_module_hosting.csrf import RequiresCsrf from simple_module_hosting.i18n_deps import TranslatorDep @@ -31,8 +31,10 @@ ) -async def tenant_token_views(service: ApiTokenService) -> list[TokenView]: - tokens = await service.list_tenant() +async def tenant_token_views( + service: ApiTokenService, *, limit: int = 100, offset: int = 0 +) -> list[TokenView]: + tokens = await service.list_tenant(limit=limit, offset=offset) emails = await service.owner_emails(tokens) return [ service.to_view(t, emails.get(t.owner_user_id) if t.owner_user_id else None) for t in tokens @@ -40,9 +42,14 @@ async def tenant_token_views(service: ApiTokenService) -> list[TokenView]: @router.get("/tokens", response_model=list[TokenView]) -async def list_all(service: ApiTokenServiceDep, t: TranslatorDep) -> list[TokenView]: +async def list_all( + service: ApiTokenServiceDep, + t: TranslatorDep, + limit: int = Query(100, ge=1, le=100), + offset: int = Query(0, ge=0), +) -> list[TokenView]: try: - return await tenant_token_views(service) + return await tenant_token_views(service, limit=limit, offset=offset) except TenantIsolationError: raise HTTPException( status_code=409, detail=t.t("api_tokens.errors.tenant_required") diff --git a/modules/api_tokens/api_tokens/endpoints/api.py b/modules/api_tokens/api_tokens/endpoints/api.py index 61044b1a..e6d49c7f 100644 --- a/modules/api_tokens/api_tokens/endpoints/api.py +++ b/modules/api_tokens/api_tokens/endpoints/api.py @@ -9,7 +9,7 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from simple_module_db import TenantIsolationError from simple_module_hosting.csrf import RequiresCsrf from simple_module_hosting.i18n_deps import TranslatorDep @@ -45,9 +45,13 @@ @router.get("/", response_model=list[TokenView]) async def list_tokens( - user: SessionPrincipal, service: ApiTokenServiceDep, t: TranslatorDep + user: SessionPrincipal, + service: ApiTokenServiceDep, + t: TranslatorDep, + limit: int = Query(100, ge=1, le=100), + offset: int = Query(0, ge=0), ) -> list[TokenView]: - tokens = await service.list_for_owner(user_uuid(user, t)) + tokens = await service.list_for_owner(user_uuid(user, t), limit=limit, offset=offset) return [service.to_view(token) for token in tokens] diff --git a/modules/api_tokens/api_tokens/endpoints/views.py b/modules/api_tokens/api_tokens/endpoints/views.py index ae05a761..7ab8c7b2 100644 --- a/modules/api_tokens/api_tokens/endpoints/views.py +++ b/modules/api_tokens/api_tokens/endpoints/views.py @@ -7,7 +7,7 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from simple_module_db import TenantIsolationError from simple_module_hosting.csrf import get_csrf_token from simple_module_hosting.i18n_deps import TranslatorDep @@ -56,12 +56,15 @@ async def index( service: ApiTokenServiceDep, settings: SettingsDep, t: TranslatorDep, + limit: int = Query(100, ge=1, le=100), + offset: int = Query(0, ge=0), ) -> InertiaResponse: - tokens = await service.list_for_owner(user_uuid(user, t)) + tokens = await service.list_for_owner(user_uuid(user, t), limit=limit + 1, offset=offset) return await inertia.render( PAGE_INDEX, { - "tokens": [service.to_view(tok).model_dump(mode="json") for tok in tokens], + "tokens": [service.to_view(tok).model_dump(mode="json") for tok in tokens[:limit]], + "pagination": {"limit": limit, "offset": offset, "has_more": len(tokens) > limit}, "scopes": await assignable_scopes(request), **_form_props(request, settings.max_lifetime_days), }, @@ -74,9 +77,11 @@ async def admin_index( inertia: InertiaDep, service: ApiTokenServiceDep, settings: SettingsDep, + limit: int = Query(100, ge=1, le=100), + offset: int = Query(0, ge=0), ) -> InertiaResponse: try: - views = await tenant_token_views(service) + views = await tenant_token_views(service, limit=limit + 1, offset=offset) tenant_required = False except TenantIsolationError: # Strict tenancy with no active tenant: nothing to list, and nowhere @@ -85,7 +90,8 @@ async def admin_index( return await inertia.render( PAGE_ADMIN, { - "tokens": [v.model_dump(mode="json") for v in views], + "tokens": [v.model_dump(mode="json") for v in views[:limit]], + "pagination": {"limit": limit, "offset": offset, "has_more": len(views) > limit}, "scopes": await assignable_scopes(request), "roles": assignable_roles(request), "tenant_required": tenant_required, diff --git a/modules/api_tokens/api_tokens/pages/Admin.tsx b/modules/api_tokens/api_tokens/pages/Admin.tsx index 413ac100..0182b22b 100644 --- a/modules/api_tokens/api_tokens/pages/Admin.tsx +++ b/modules/api_tokens/api_tokens/pages/Admin.tsx @@ -11,6 +11,7 @@ import { api, csrfHeaders } from './api'; import { CreatedTokenDialog } from './components/CreatedTokenDialog'; import { CreateTokenDialog } from './components/CreateTokenDialog'; import { RevokeTokenDialog } from './components/RevokeTokenDialog'; +import { TokenPagination } from './components/TokenPagination'; import { TokenTable } from './components/TokenTable'; import { useTokenActions } from './components/useTokenActions'; import type { CreatePayload, TokenFormProps } from './types'; @@ -72,6 +73,7 @@ function Admin(props: Props) { onRevoke={actions.setPendingRevoke} /> )} + apiResult(api.POST('/api/api_tokens/tokens/revoke-all', { headers })), }); - const hasLive = props.tokens.some((tok) => tokenStatus(tok) === 'active'); + // Unseen pages may still contain live tokens. + const hasLive = + props.pagination.has_more || + props.pagination.offset > 0 || + props.tokens.some((tok) => tokenStatus(tok) === 'active'); async function submit(payload: CreatePayload) { const ok = await actions.create(payload); @@ -64,6 +69,7 @@ function Index(props: TokenFormProps) { >

{t(keys.api_tokens.index.usage)}

+ ; export interface operations { api_tokens_list_tokens: { parameters: { - query?: never; + query?: { + limit?: number; + offset?: number; + }; header?: never; path?: never; cookie?: never; @@ -251,6 +254,15 @@ export interface operations { "application/json": components["schemas"]["TokenView"][]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; api_tokens_create_token: { @@ -339,7 +351,10 @@ export interface operations { }; api_tokens_list_all: { parameters: { - query?: never; + query?: { + limit?: number; + offset?: number; + }; header?: never; path?: never; cookie?: never; @@ -355,6 +370,15 @@ export interface operations { "application/json": components["schemas"]["TokenView"][]; }; }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; }; }; api_tokens_create_service_key: { diff --git a/modules/api_tokens/api_tokens/pages/components/TokenPagination.tsx b/modules/api_tokens/api_tokens/pages/components/TokenPagination.tsx new file mode 100644 index 00000000..d1580b38 --- /dev/null +++ b/modules/api_tokens/api_tokens/pages/components/TokenPagination.tsx @@ -0,0 +1,25 @@ +import { router } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import type { TokenFormProps } from '../types'; + +export function TokenPagination({ limit, offset, has_more }: TokenFormProps['pagination']) { + const { t } = useT(); + function visit(nextOffset: number) { + router.get(window.location.pathname, { limit, offset: nextOffset }, { preserveState: true }); + } + return ( +
+ + +
+ ); +} diff --git a/modules/api_tokens/api_tokens/pages/components/useTokenActions.ts b/modules/api_tokens/api_tokens/pages/components/useTokenActions.ts index 0e842520..b70e3678 100644 --- a/modules/api_tokens/api_tokens/pages/components/useTokenActions.ts +++ b/modules/api_tokens/api_tokens/pages/components/useTokenActions.ts @@ -13,7 +13,7 @@ export interface TokenCalls { revokeAll?: () => Promise>; } -/** Create / revoke through the JSON API, then reload the page's `tokens` prop. */ +/** Create / revoke through the JSON API, then reload the tokens and pagination. */ export function useTokenActions({ create: createCall, revoke, revokeAll }: TokenCalls) { const { t } = useT(); const [created, setCreated] = useState(null); @@ -30,7 +30,7 @@ export function useTokenActions({ create: createCall, revoke, revokeAll }: Token return false; } setCreated(result.data.token); - router.reload({ only: ['tokens'] }); + router.reload({ only: ['tokens', 'pagination'] }); return true; } @@ -45,7 +45,7 @@ export function useTokenActions({ create: createCall, revoke, revokeAll }: Token return; } toast.success(t(keys.api_tokens.toasts.revoked)); - router.reload({ only: ['tokens'] }); + router.reload({ only: ['tokens', 'pagination'] }); } async function confirmRevokeAll() { @@ -59,7 +59,7 @@ export function useTokenActions({ create: createCall, revoke, revokeAll }: Token return; } toast.success(t(keys.api_tokens.toasts.revoked_all, { count: result.data.revoked })); - router.reload({ only: ['tokens'] }); + router.reload({ only: ['tokens', 'pagination'] }); } return { diff --git a/modules/api_tokens/api_tokens/pages/types.ts b/modules/api_tokens/api_tokens/pages/types.ts index 486c9aa9..3b8c7a2a 100644 --- a/modules/api_tokens/api_tokens/pages/types.ts +++ b/modules/api_tokens/api_tokens/pages/types.ts @@ -17,6 +17,7 @@ export interface ApiToken { /** Props both pages receive for their create form. */ export interface TokenFormProps { tokens: ApiToken[]; + pagination: { limit: number; offset: number; has_more: boolean }; scopes: string[]; max_lifetime_days: number; expiry_presets: number[]; diff --git a/modules/api_tokens/api_tokens/service.py b/modules/api_tokens/api_tokens/service.py index 8fe2acb0..e7ca8f06 100644 --- a/modules/api_tokens/api_tokens/service.py +++ b/modules/api_tokens/api_tokens/service.py @@ -11,8 +11,9 @@ import uuid from collections.abc import Sequence -from datetime import UTC, datetime, timedelta +from datetime import timedelta +from simple_module_core.datetimes import utcnow as _utcnow from simple_module_core.user_directory import NullUserDirectory, UserDirectory from simple_module_db import ALL_TENANTS_OPTION, all_tenants from sqlalchemy import select @@ -35,10 +36,6 @@ def __init__(self, key: str, **params: object) -> None: self.params = params -def _utcnow() -> datetime: - return datetime.now(UTC) - - class ApiTokenService: def __init__(self, db: AsyncSession, directory: UserDirectory | None = None) -> None: self.db = db @@ -98,17 +95,24 @@ async def has_usable_account(self, user_id: uuid.UUID) -> bool: async def revoke_all_for_owner(self, owner_user_id: uuid.UUID) -> int: """Revoke every live personal token *owner_user_id* holds, in any tenant.""" - tokens = [t for t in await self.list_for_owner(owner_user_id) if t.revoked_at is None] + revoked = 0 + offset = 0 now = _utcnow() - for token in tokens: - token.revoked_at = now - if tokens: - with all_tenants(): - await self.db.flush() - return len(tokens) + while tokens := await self.list_for_owner(owner_user_id, offset=offset): + live = [t for t in tokens if t.revoked_at is None] + for token in live: + token.revoked_at = now + if live: + with all_tenants(): + await self.db.flush() + revoked += len(live) + offset += len(tokens) + return revoked - async def list_for_owner(self, owner_user_id: uuid.UUID) -> list[ApiToken]: - """Every personal token *owner_user_id* holds, in any tenant. + async def list_for_owner( + self, owner_user_id: uuid.UUID, *, limit: int = 100, offset: int = 0 + ) -> list[ApiToken]: + """A page of personal tokens *owner_user_id* holds, in any tenant. Across tenants on purpose: a token is its owner's, and one created while another organisation was active must still be listable — and @@ -120,14 +124,21 @@ async def list_for_owner(self, owner_user_id: uuid.UUID) -> list[ApiToken]: col(ApiToken.owner_user_id) == owner_user_id, col(ApiToken.kind) == TokenKind.PERSONAL, ) - .order_by(ApiToken.created_at.desc()) + .order_by(ApiToken.created_at.desc(), ApiToken.id.desc()) + .limit(limit) + .offset(offset) .execution_options(**{ALL_TENANTS_OPTION: True}) ) return list((await self.db.execute(stmt)).scalars()) - async def list_tenant(self) -> list[ApiToken]: - """Every token in the active tenant (tenant-filtered, fail-closed).""" - stmt = select(ApiToken).order_by(ApiToken.created_at.desc()) + async def list_tenant(self, *, limit: int = 100, offset: int = 0) -> list[ApiToken]: + """A page of tokens in the active tenant (tenant-filtered, fail-closed).""" + stmt = ( + select(ApiToken) + .order_by(ApiToken.created_at.desc(), ApiToken.id.desc()) + .limit(limit) + .offset(offset) + ) return list((await self.db.execute(stmt)).scalars()) async def get_owned(self, token_id: uuid.UUID, owner_user_id: uuid.UUID) -> ApiToken | None: diff --git a/modules/api_tokens/tests-js/api_tokens_Admin.test.tsx b/modules/api_tokens/tests-js/api_tokens_Admin.test.tsx index 584075ec..5e7f657f 100644 --- a/modules/api_tokens/tests-js/api_tokens_Admin.test.tsx +++ b/modules/api_tokens/tests-js/api_tokens_Admin.test.tsx @@ -43,6 +43,7 @@ function setProps(over: Partial> = {}) { tenant_required: false, max_lifetime_days: 365, expiry_presets: [7, 30], + pagination: { limit: 100, offset: 0, has_more: false }, csrf_token: 'csrf-xyz', ...over, }; diff --git a/modules/api_tokens/tests-js/api_tokens_Index.test.tsx b/modules/api_tokens/tests-js/api_tokens_Index.test.tsx index 1db860ff..708978f9 100644 --- a/modules/api_tokens/tests-js/api_tokens_Index.test.tsx +++ b/modules/api_tokens/tests-js/api_tokens_Index.test.tsx @@ -30,6 +30,7 @@ function setProps(over: Partial> = {}) { scopes: ['files.read', 'files.write'], max_lifetime_days: 365, expiry_presets: [7, 30, 90], + pagination: { limit: 100, offset: 0, has_more: false }, csrf_token: 'csrf-abc', ...over, }; @@ -100,7 +101,7 @@ describe('api_tokens Index', () => { scopes: ['files.read'], expires_in_days: 30, }); - expect(reload).toHaveBeenCalledWith({ only: ['tokens'] }); + expect(reload).toHaveBeenCalledWith({ only: ['tokens', 'pagination'] }); }); it('removes the secret once dismissed and never renders it after a reload', async () => { @@ -168,7 +169,7 @@ describe('api_tokens Index', () => { await waitFor(() => expect(toast.success).toHaveBeenCalledWith(keys.api_tokens.toasts.revoked)); expect((await sent(fetchMock)).path).toBe('/api/api_tokens/tokens/t1/revoke'); - expect(reload).toHaveBeenCalledWith({ only: ['tokens'] }); + expect(reload).toHaveBeenCalledWith({ only: ['tokens', 'pagination'] }); }); it('toasts an error and does not reload when revoke fails', async () => { @@ -201,4 +202,13 @@ describe('api_tokens Index', () => { await waitFor(() => expect(screen.queryByText(ra.description)).toBeNull()); expect(fetchMock).not.toHaveBeenCalled(); }); + + it('keeps revoke-all available when other pages may hold live tokens', () => { + setProps({ + tokens: [token({ revoked_at: '2026-02-01T00:00:00Z' })], + pagination: { limit: 1, offset: 0, has_more: true }, + }); + render(); + expect(screen.getByRole('button', { name: ra.button })).toBeEnabled(); + }); }); diff --git a/modules/api_tokens/tests/test_api_tokens_pagination.py b/modules/api_tokens/tests/test_api_tokens_pagination.py new file mode 100644 index 00000000..a47a8a1e --- /dev/null +++ b/modules/api_tokens/tests/test_api_tokens_pagination.py @@ -0,0 +1,66 @@ +"""Bound token lists without truncating revoke-all (#640).""" + +import uuid + +import pytest +from _api_tokens_support import INERTIA_HEADERS, PAT_URL, create_pat +from api_tokens.constants import TokenKind +from api_tokens.service import ApiTokenService +from simple_module_db.tenancy import tenant_context + + +async def test_service_pages_and_revoke_all(db_session): + service = ApiTokenService(db_session) + owner = uuid.uuid4() + with tenant_context("pagination"): + made = [] + for i in range(105): + token, _ = await service.create( + kind=TokenKind.PERSONAL, + name=str(i), + scopes=["api_tokens.use"], + expires_in_days=1, + max_lifetime_days=1, + owner_user_id=owner, + ) + made.append(token) + assert len(await service.list_tenant()) == 100 + assert len(await service.list_for_owner(owner)) == 100 + for listing in (service.list_tenant, lambda **kw: service.list_for_owner(owner, **kw)): + first = await listing(limit=3) + second = await listing(limit=3, offset=3) + assert len(first) == len(second) == 3 + assert not {t.id for t in first} & {t.id for t in second} + assert await service.revoke_all_for_owner(owner) == 105 + assert all(t.revoked_at is not None for t in made) + assert await service.revoke_all_for_owner(owner) == 0 + + +@pytest.mark.parametrize("url", [PAT_URL, "/api/api_tokens/admin/tokens"]) +async def test_http_pages_and_bounds(actor, url): + async with actor(admin=True) as a: + for _ in range(3): + await create_pat(a.client, ["api_tokens.use"]) + first = await a.client.get(url, params={"limit": 1}) + second = await a.client.get(url, params={"limit": 1, "offset": 1}) + assert first.status_code == second.status_code == 200 + assert len(first.json()) == len(second.json()) == 1 + assert first.json()[0]["id"] != second.json()[0]["id"] + for params in ({"limit": 0}, {"limit": 101}, {"offset": -1}): + assert (await a.client.get(url, params=params)).status_code == 422 + + +@pytest.mark.parametrize("url", ["/api-tokens/", "/admin/api-tokens/"]) +async def test_pages_expose_navigation_without_extra_row(actor, url): + async with actor(admin=True) as a: + for _ in range(3): + await create_pat(a.client, ["api_tokens.use"]) + first = await a.client.get(url, params={"limit": 2}, headers=INERTIA_HEADERS) + assert first.status_code == 200 + props = first.json()["props"] + assert len(props["tokens"]) == 2 + assert props["pagination"] == {"limit": 2, "offset": 0, "has_more": True} + last = await a.client.get(url, params={"limit": 2, "offset": 2}, headers=INERTIA_HEADERS) + props = last.json()["props"] + assert len(props["tokens"]) == 1 + assert props["pagination"] == {"limit": 2, "offset": 2, "has_more": False} diff --git a/modules/background_tasks/background_tasks/outbox_clock.py b/modules/background_tasks/background_tasks/outbox_clock.py index 1615e424..8e8c30fd 100644 --- a/modules/background_tasks/background_tasks/outbox_clock.py +++ b/modules/background_tasks/background_tasks/outbox_clock.py @@ -22,6 +22,8 @@ from datetime import UTC, datetime from typing import Any +from simple_module_core.datetimes import aware as _aware +from simple_module_core.datetimes import utcnow from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -30,10 +32,6 @@ def _is_postgres(session: Any) -> bool: return session.get_bind().dialect.name == "postgresql" -def _aware(value: datetime) -> datetime: - return value if value.tzinfo is not None else value.replace(tzinfo=UTC) - - async def db_now(session: Any) -> datetime: """The database's current time, as an aware UTC ``datetime``. @@ -41,7 +39,7 @@ async def db_now(session: Any) -> datetime: hold either). """ if not _is_postgres(session): - return datetime.now(UTC) + return utcnow() stmt = select(func.clock_timestamp()) if isinstance(session, Session): value = session.execute(stmt).scalar_one() diff --git a/modules/sharing/sharing/service.py b/modules/sharing/sharing/service.py index 676cf2fe..9067af45 100644 --- a/modules/sharing/sharing/service.py +++ b/modules/sharing/sharing/service.py @@ -9,9 +9,10 @@ from __future__ import annotations import uuid -from datetime import UTC, datetime from typing import Any +from simple_module_core.datetimes import aware as _aware +from simple_module_core.datetimes import utcnow from simple_module_core.resources import ( ACTION_SHARE, ResourcePrincipal, @@ -20,6 +21,7 @@ ) from simple_module_core.user_directory import UserDirectory from simple_module_db.resource_policy import load_authorized +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select @@ -49,10 +51,6 @@ def __init__(self, status_code: int, detail: str) -> None: self.detail = detail -def _aware(value: datetime) -> datetime: - return value if value.tzinfo is not None else value.replace(tzinfo=UTC) - - class SharingService: def __init__( self, @@ -93,7 +91,7 @@ async def grant(self, principal: ResourcePrincipal, data: ShareGrant) -> Resourc resource_type = self._type(data.resource_type) if data.role not in resource_type.shareable_actions: raise SharingError(422, ERR_BAD_ROLE) - if data.expires_at is not None and _aware(data.expires_at) <= datetime.now(UTC): + if data.expires_at is not None and _aware(data.expires_at) <= utcnow(): raise SharingError(422, ERR_EXPIRED) row = await self._authorize(principal, resource_type, data.resource_id) grantee = (await self.directory.get_many(self.db, [data.subject_id])).get(data.subject_id) @@ -112,8 +110,17 @@ async def grant(self, principal: ResourcePrincipal, data: ShareGrant) -> Resourc subject_id=grantee.id, role=data.role, granted_by=principal.user_id or "", + expires_at=data.expires_at, ) - self.db.add(share) + try: + async with self.db.begin_nested(): + self.db.add(share) + await self.db.flush() + except IntegrityError: + # Another grant may have won the unique-key race. + share = await self._existing(data.resource_type, resource_id, grantee.id) + if share is None: + raise share.role = data.role share.expires_at = data.expires_at share.granted_by = principal.user_id or "" diff --git a/modules/sharing/tests/test_sharing_service.py b/modules/sharing/tests/test_sharing_service.py index 21fef5f6..3bbf4761 100644 --- a/modules/sharing/tests/test_sharing_service.py +++ b/modules/sharing/tests/test_sharing_service.py @@ -204,3 +204,24 @@ async def test_expired_share_can_be_renewed_without_duplicate_grants(env): assert renewed.id == share.id assert await _visible(service.db, FRIEND, ACTION_EDIT) == ["n"] assert len(await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)) == 1 + + +async def test_duplicate_insert_race_reuses_winner_and_preserves_transaction(env, monkeypatch): + service, note_id = env + with tenant_context("t1"): + winner = await service.grant(_who(OWNER), _grant(note_id)) + original = service._existing + calls = 0 + + async def miss_once(*args): + nonlocal calls + calls += 1 + return None if calls == 1 else await original(*args) + + # Simulate a concurrent winner becoming visible after our initial lookup. + monkeypatch.setattr(service, "_existing", miss_once) + result = await service.grant(_who(OWNER), _grant(note_id, role=ACTION_EDIT)) + assert result.id == winner.id + assert result.role == ACTION_EDIT + assert len(await service.for_resource(_who(OWNER), NOTE_TYPE, note_id)) == 1 + assert await _visible(service.db, FRIEND, ACTION_EDIT) == ["n"] diff --git a/modules/site_lock/site_lock/rate_limit.py b/modules/site_lock/site_lock/rate_limit.py index 4df91f43..dfad22e6 100644 --- a/modules/site_lock/site_lock/rate_limit.py +++ b/modules/site_lock/site_lock/rate_limit.py @@ -32,17 +32,32 @@ def __init__( self._window = window_seconds self._cooldown = cooldown_seconds self._buckets: dict[str, _Bucket] = {} + self._next_sweep = 0.0 @staticmethod def _now(now: float | None) -> float: return time.monotonic() if now is None else now + def _sweep(self, moment: float) -> None: + if moment < self._next_sweep: + return + self._buckets = { + key: bucket + for key, bucket in self._buckets.items() + if bucket.blocked_until > moment + or (bucket.failures and moment - bucket.failures[-1] < self._window) + } + self._next_sweep = moment + self._window + def is_blocked(self, key: str, *, now: float | None = None) -> bool: + moment = self._now(now) + self._sweep(moment) bucket = self._buckets.get(key) - return bucket is not None and bucket.blocked_until > self._now(now) + return bucket is not None and bucket.blocked_until > moment def record_failure(self, key: str, *, now: float | None = None) -> None: moment = self._now(now) + self._sweep(moment) bucket = self._buckets.setdefault(key, _Bucket()) bucket.failures = [t for t in bucket.failures if moment - t < self._window] bucket.failures.append(moment) diff --git a/modules/site_lock/tests/test_site_lock_rate_limit.py b/modules/site_lock/tests/test_site_lock_rate_limit.py index d270aefb..5412def3 100644 --- a/modules/site_lock/tests/test_site_lock_rate_limit.py +++ b/modules/site_lock/tests/test_site_lock_rate_limit.py @@ -58,3 +58,15 @@ def test_reset_clears_a_block() -> None: lim.record_failure("1.2.3.4", now=float(i)) lim.reset("1.2.3.4") assert lim.is_blocked("1.2.3.4", now=3.0) is False + + +def test_expired_keys_are_collected_without_dropping_live_cooldowns(): + lim = _limiter() + lim.record_failure("stale", now=0) + for i in range(3): + lim.record_failure("blocked", now=90 + i) + lim.record_failure("fresh", now=101) + assert "stale" not in lim._buckets + assert lim.is_blocked("blocked", now=101) + lim.is_blocked("visitor", now=700) + assert not lim._buckets diff --git a/modules/tenants/tenants/invitations.py b/modules/tenants/tenants/invitations.py index faaeb818..d9b1e008 100644 --- a/modules/tenants/tenants/invitations.py +++ b/modules/tenants/tenants/invitations.py @@ -8,9 +8,11 @@ import hashlib import secrets -from datetime import UTC, datetime, timedelta +from datetime import timedelta from functools import partial +from simple_module_core.datetimes import aware as _aware +from simple_module_core.datetimes import utcnow as _now from sqlalchemy import delete, func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -31,15 +33,6 @@ def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() -def _now() -> datetime: - return datetime.now(UTC) - - -def _aware(value: datetime) -> datetime: - # SQLite hands back naive datetimes even for timezone=True columns. - return value if value.tzinfo else value.replace(tzinfo=UTC) - - def _pending_clause(tenant_id: str): return ( (Invitation.tenant_id == tenant_id) diff --git a/modules/users/tests/test_users_update_schema.py b/modules/users/tests/test_users_update_schema.py new file mode 100644 index 00000000..caf5d4d8 --- /dev/null +++ b/modules/users/tests/test_users_update_schema.py @@ -0,0 +1,11 @@ +"""Self-service updates cannot carry account administration fields (#640).""" + +import pytest +from users.contracts.schemas import UserUpdate + + +@pytest.mark.parametrize("field", ["is_superuser", "is_active", "is_verified"]) +def test_user_update_drops_privileged_fields(field): + update = UserUpdate.model_validate({field: True, "full_name": "Alice"}) + assert update.model_dump(exclude_unset=True) == {"full_name": "Alice"} + assert field not in UserUpdate.model_json_schema()["properties"] diff --git a/modules/users/users/auth_local/lockout/__init__.py b/modules/users/users/auth_local/lockout/__init__.py index 32620169..5d0368f0 100644 --- a/modules/users/users/auth_local/lockout/__init__.py +++ b/modules/users/users/auth_local/lockout/__init__.py @@ -29,10 +29,11 @@ from __future__ import annotations import math -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any from fastapi import Request +from simple_module_core.datetimes import utcnow as _utcnow from simple_module_core.rate_limit import RateSpec from simple_module_db import independent_session from sqlalchemy import case, func, select, update @@ -50,10 +51,6 @@ LOCKED_DETAIL = LOGIN_LOCKED_DETAIL -def _utcnow() -> datetime: - return datetime.now(UTC) - - def login_attempt(request: Request, username: str) -> LoginAttempt: """The :class:`LoginAttempt` for *username* arriving on *request*.""" return LoginAttempt.build( diff --git a/modules/users/users/contracts/schemas.py b/modules/users/users/contracts/schemas.py index 7b28a47f..abc4960e 100644 --- a/modules/users/users/contracts/schemas.py +++ b/modules/users/users/contracts/schemas.py @@ -63,9 +63,6 @@ class UserCreate(CreateUpdateDictModel, SQLModel): class UserUpdate(CreateUpdateDictModel, SQLModel): password: str | None = None email: EmailStr | None = None - is_active: bool | None = None - is_superuser: bool | None = None - is_verified: bool | None = None full_name: str | None = None