simple-module-api-tokens 0.0.37__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ {
2
+ "nav": {
3
+ "api_tokens": "API tokens"
4
+ },
5
+ "audit": {
6
+ "token": "API token"
7
+ },
8
+ "errors": {
9
+ "session_required": "API tokens can only be managed from a signed-in browser session, not with a token.",
10
+ "scopes_required": "Choose at least one scope.",
11
+ "scope_not_held": "You cannot grant the scope \"{value}\": you do not hold it.",
12
+ "role_forbidden": "A service key cannot have the \"{role}\" role.",
13
+ "role_not_assignable": "The role \"{value}\" cannot be given to a service key.",
14
+ "lifetime_too_long": "A token can be valid for at most {days} days.",
15
+ "invalid_kind": "This kind of token cannot be created here.",
16
+ "tenant_required": "Choose an organisation before managing its tokens.",
17
+ "not_found": "Token not found.",
18
+ "account_required": "Your account has no local user record, so a personal access token could never sign in as you. Ask an administrator for a service key instead."
19
+ },
20
+ "index": {
21
+ "title": "API tokens",
22
+ "description": "Personal access tokens let scripts and tools call the API as you, limited to the scopes you choose.",
23
+ "usage": "Send a token in the Authorization header: Authorization: Bearer <token>."
24
+ },
25
+ "admin": {
26
+ "title": "API tokens",
27
+ "description": "Every personal access token and service key in this organisation.",
28
+ "tenant_required": "Choose an organisation to see and create its API tokens.",
29
+ "new_service_key": "New service key"
30
+ },
31
+ "list": {
32
+ "name": "Name",
33
+ "token": "Token",
34
+ "kind": "Kind",
35
+ "owner": "Owner",
36
+ "scopes": "Scopes",
37
+ "roles": "Roles",
38
+ "expires": "Expires",
39
+ "last_used": "Last used",
40
+ "status": "Status",
41
+ "actions": "Actions",
42
+ "never": "Never",
43
+ "revoke": "Revoke",
44
+ "empty_title": "No API tokens yet",
45
+ "empty_description": "Create a token to call the API from a script or another service.",
46
+ "scope_count_one": "{count} scope",
47
+ "scope_count_other": "{count} scopes"
48
+ },
49
+ "kind": {
50
+ "personal": "Personal",
51
+ "service": "Service key"
52
+ },
53
+ "status": {
54
+ "active": "Active",
55
+ "revoked": "Revoked",
56
+ "expired": "Expired"
57
+ },
58
+ "form": {
59
+ "new_token": "New token",
60
+ "create_title": "Create a personal access token",
61
+ "create_service_title": "Create a service key",
62
+ "create_description": "The token can do only what its scopes allow, and never more than you can.",
63
+ "create_service_description": "A service key acts for no person. It gets the roles you pick, limited to the scopes you choose.",
64
+ "name": "Name",
65
+ "name_placeholder": "e.g. CI deploy",
66
+ "scopes": "Scopes",
67
+ "scopes_hint": "Only permissions you hold are listed.",
68
+ "roles": "Roles",
69
+ "roles_hint": "The administrator role cannot be given to a service key.",
70
+ "no_roles": "No assignable roles.",
71
+ "expiry": "Expires after",
72
+ "days_one": "{count} day",
73
+ "days_other": "{count} days",
74
+ "submit": "Create token",
75
+ "cancel": "Cancel"
76
+ },
77
+ "created": {
78
+ "title": "Copy your new token",
79
+ "warning": "This is the only time the token is shown. Store it somewhere safe now — it cannot be recovered.",
80
+ "copy": "Copy",
81
+ "copied": "Copied",
82
+ "done": "Done"
83
+ },
84
+ "revoke": {
85
+ "title": "Revoke \"{name}\"?",
86
+ "description": "Anything using this token stops working on its next request. This cannot be undone.",
87
+ "confirm": "Revoke",
88
+ "cancel": "Cancel"
89
+ },
90
+ "toasts": {
91
+ "created": "Token created",
92
+ "revoked": "Token revoked",
93
+ "failed": "Something went wrong. Please try again.",
94
+ "revoked_all_one": "{count} token revoked",
95
+ "revoked_all_other": "{count} tokens revoked"
96
+ },
97
+ "revoke_all": {
98
+ "button": "Revoke all",
99
+ "title": "Revoke all your tokens?",
100
+ "description": "Every personal access token you hold stops working on its next request, in every organisation. Use this if a token may have leaked: changing your password or signing out everywhere does not end tokens.",
101
+ "confirm": "Revoke all",
102
+ "cancel": "Cancel"
103
+ }
104
+ }
api_tokens/models.py ADDED
@@ -0,0 +1,58 @@
1
+ """SQLModel tables for the api_tokens module.
2
+
3
+ One table holds both credential kinds. Only ``sha256(token)`` is stored; the
4
+ secret itself is shown once, in the response that created it, and nowhere
5
+ else. ``prefix`` is a short, non-secret slice so a person can tell their
6
+ tokens apart in a list.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from datetime import datetime
13
+ from typing import ClassVar
14
+
15
+ import sqlalchemy as sa
16
+ from fastapi_users_db_sqlalchemy.generics import GUID
17
+ from simple_module_db.base import create_module_base
18
+ from simple_module_db.mixins import AuditMixin, MultiTenantMixin
19
+ from simple_module_db.types import AwareDateTime
20
+ from sqlalchemy import Index
21
+ from sqlmodel import Field
22
+
23
+ from api_tokens.constants import IX_TOKEN_HASH, NAME_MAX_LENGTH, TABLE_TOKEN, TokenKind
24
+
25
+ Base = create_module_base("api_tokens")
26
+
27
+
28
+ class ApiToken(Base, AuditMixin, MultiTenantMixin, table=True): # ty: ignore[unsupported-base]
29
+ """A personal access token or a service API key."""
30
+
31
+ __tablename__ = TABLE_TOKEN
32
+ # The hash is a credential-equivalent lookup key and ``last_used_at`` is
33
+ # bumped by a Core UPDATE that never reaches the audit listener anyway;
34
+ # neither belongs in the audit trail. Create and revoke still do.
35
+ __audit_exclude_fields__: ClassVar[set[str]] = {"token_hash", "last_used_at"}
36
+ # ``token_hash`` leads, so the per-request lookup by hash alone is an
37
+ # index seek; ``tenant_id`` is in the key because every unique key on a
38
+ # tenant-scoped table must be (SM024). A SHA-256 of 32 random bytes does
39
+ # not collide, so the wider key loses nothing.
40
+ __table_args__ = (Index(IX_TOKEN_HASH, "token_hash", "tenant_id", unique=True),)
41
+
42
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
43
+ kind: str = Field(max_length=16, default=TokenKind.PERSONAL)
44
+ name: str = Field(max_length=NAME_MAX_LENGTH)
45
+ prefix: str = Field(max_length=16)
46
+ # Looked up before any tenant is known — see ``__table_args__``.
47
+ token_hash: str = Field(max_length=64)
48
+ # ``GUID``, the type ``users_user.id`` has, so the values compare equal on
49
+ # SQLite too (``sa.Uuid`` stores bare hex there — GH #339). No foreign key:
50
+ # every module owns its own ``MetaData``, so one cannot name another
51
+ # module's table. A deleted owner is handled where it matters — the
52
+ # resolver finds no account and refuses the token.
53
+ owner_user_id: uuid.UUID | None = Field(default=None, sa_type=GUID, index=True)
54
+ scopes: list[str] = Field(default_factory=list, sa_type=sa.JSON())
55
+ roles: list[str] = Field(default_factory=list, sa_type=sa.JSON())
56
+ expires_at: datetime = Field(sa_type=AwareDateTime())
57
+ last_used_at: datetime | None = Field(default=None, sa_type=AwareDateTime())
58
+ revoked_at: datetime | None = Field(default=None, sa_type=AwareDateTime())
api_tokens/module.py ADDED
@@ -0,0 +1,109 @@
1
+ """ApiTokens module: personal access tokens and service API keys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.resources
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
11
+ from simple_module_core.module import ModuleBase, ModuleMeta
12
+ from simple_module_core.tenancy import TenantRole, tenant_role
13
+
14
+ from api_tokens import constants as c
15
+
16
+ if TYPE_CHECKING:
17
+ from fastapi import APIRouter, FastAPI
18
+ from simple_module_core.audit_links import AuditLinkRegistry
19
+ from simple_module_core.permissions import PermissionRegistry
20
+
21
+
22
+ class ApiTokensModule(ModuleBase):
23
+ meta = ModuleMeta(
24
+ name=c.MODULE_NAME,
25
+ route_prefix=c.API_PREFIX,
26
+ view_prefix=c.VIEW_PREFIX,
27
+ admin_view_prefix=c.ADMIN_VIEW_PREFIX,
28
+ # Auth owns the resolver chain this module appends to; Users owns the
29
+ # accounts a personal token acts for; Settings stores the lifetime cap.
30
+ depends_on=[c.MODULE_AUTH, c.MODULE_USERS, c.MODULE_SETTINGS],
31
+ )
32
+
33
+ def register_settings(self, app: FastAPI) -> None:
34
+ from api_tokens.services import ApiTokensServices
35
+ from api_tokens.settings import ApiTokensSettings
36
+
37
+ # SM009 is AST-based: resolve via importlib like the other
38
+ # settings-backed modules.
39
+ register_module_settings = importlib.import_module(
40
+ "settings.registration"
41
+ ).register_module_settings
42
+ register_module_settings(
43
+ app,
44
+ c.MODULE_PACKAGE,
45
+ ApiTokensSettings,
46
+ lambda s: ApiTokensServices(settings=s),
47
+ )
48
+
49
+ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
50
+ from api_tokens.endpoints.admin_api import router as admin_api
51
+ from api_tokens.endpoints.api import router as api
52
+ from api_tokens.endpoints.views import router as views
53
+
54
+ api_router.include_router(api)
55
+ api_router.include_router(admin_api)
56
+ view_router.include_router(views)
57
+
58
+ def register_admin_routes(self, admin_router: APIRouter) -> None:
59
+ from api_tokens.endpoints.views import admin_router as admin_views
60
+
61
+ admin_router.include_router(admin_views)
62
+
63
+ def register_menu_items(self, registry: MenuRegistry) -> None:
64
+ registry.add(
65
+ MenuItem(
66
+ label=c.MENU_LABEL,
67
+ label_key=c.MENU_LABEL_KEY,
68
+ url=c.USER_URL,
69
+ icon=c.MENU_ICON,
70
+ order=c.MENU_USER_ORDER,
71
+ section=MenuSection.USER_DROPDOWN,
72
+ permissions=[c.PERM_USE],
73
+ )
74
+ )
75
+ registry.add(
76
+ MenuItem(
77
+ label=c.MENU_LABEL,
78
+ label_key=c.MENU_LABEL_KEY,
79
+ url=c.ADMIN_URL,
80
+ icon=c.MENU_ICON,
81
+ order=c.MENU_ADMIN_ORDER,
82
+ section=MenuSection.ADMIN_SIDEBAR,
83
+ group="Access",
84
+ group_key="ui.nav_groups.access",
85
+ permissions=[c.PERM_MANAGE],
86
+ )
87
+ )
88
+
89
+ def register_permissions(self, registry: PermissionRegistry) -> None:
90
+ registry.add_group(c.PERM_GROUP, [c.PERM_USE, c.PERM_MANAGE])
91
+ registry.map_role(c.USER_ROLE, [c.PERM_USE])
92
+ for role in TenantRole:
93
+ registry.map_role(tenant_role(role), [c.PERM_USE])
94
+
95
+ def register_audit_links(self, registry: AuditLinkRegistry) -> None:
96
+ from api_tokens.audit import build_token_audit_link
97
+
98
+ registry.register(build_token_audit_link())
99
+
100
+ def locale_dirs(self) -> dict[str, Path]:
101
+ base = Path(str(importlib.resources.files(__package__) / "locales"))
102
+ return {c.LOCALE_NAMESPACE: base}
103
+
104
+ async def on_startup(self, app: FastAPI) -> None:
105
+ from api_tokens.resolver import resolve_api_token
106
+
107
+ resolvers = app.state.auth.principal_resolvers
108
+ if resolve_api_token not in resolvers:
109
+ resolvers.append(resolve_api_token)
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@simple-module-py/api-tokens",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Frontend assets for the ApiTokens module",
6
+ "peerDependencies": {
7
+ "react": "^19.0.0",
8
+ "react-dom": "^19.0.0",
9
+ "@inertiajs/react": "^3.7.0",
10
+ "@simple-module-py/ui": "*"
11
+ },
12
+ "devDependencies": {
13
+ "@simple-module-py/tsconfig": "*"
14
+ },
15
+ "dependencies": {
16
+ "lucide-react": "^1.43.0",
17
+ "sonner": "^2.0.8"
18
+ }
19
+ }
@@ -0,0 +1,92 @@
1
+ import { Head, usePage } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner';
4
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
5
+ import { Button } from '@simple-module-py/ui/components/ui/button';
6
+ import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
7
+ import { Building2, Plus } from 'lucide-react';
8
+ import { useState } from 'react';
9
+ import { CreatedTokenDialog } from './components/CreatedTokenDialog';
10
+ import { CreateTokenDialog } from './components/CreateTokenDialog';
11
+ import { RevokeTokenDialog } from './components/RevokeTokenDialog';
12
+ import { TokenTable } from './components/TokenTable';
13
+ import { useTokenActions } from './components/useTokenActions';
14
+ import type { CreatePayload, TokenFormProps } from './types';
15
+
16
+ interface Props extends TokenFormProps {
17
+ roles: string[];
18
+ tenant_required: boolean;
19
+ }
20
+
21
+ /** Every token in the active tenant; service keys are created here. */
22
+ function Admin() {
23
+ const props = usePage<{ props: Props }>().props as unknown as Props;
24
+ const { t } = useT();
25
+ const [creating, setCreating] = useState(false);
26
+ // An audit-log link lands here as ?token=<id>; mark that row.
27
+ const highlightId =
28
+ typeof window === 'undefined' ? null : new URLSearchParams(window.location.search).get('token');
29
+ const actions = useTokenActions({
30
+ csrfToken: props.csrf_token,
31
+ createUrl: `${props.api_url}/admin/service-keys`,
32
+ revokeUrl: (id) => `${props.api_url}/admin/tokens/${id}/revoke`,
33
+ });
34
+
35
+ async function submit(payload: CreatePayload) {
36
+ const ok = await actions.create(payload);
37
+ if (ok) setCreating(false);
38
+ return ok;
39
+ }
40
+
41
+ return (
42
+ <>
43
+ <Head title={t(keys.api_tokens.admin.title)} />
44
+ <PageShell
45
+ title={t(keys.api_tokens.admin.title)}
46
+ description={t(keys.api_tokens.admin.description)}
47
+ actions={
48
+ <Button onClick={() => setCreating(true)} disabled={props.tenant_required}>
49
+ <Plus className="size-4" />
50
+ {t(keys.api_tokens.admin.new_service_key)}
51
+ </Button>
52
+ }
53
+ >
54
+ {props.tenant_required ? (
55
+ <InlineBanner
56
+ icon={Building2}
57
+ tone="warning"
58
+ title={t(keys.api_tokens.admin.tenant_required)}
59
+ />
60
+ ) : (
61
+ <TokenTable
62
+ tokens={props.tokens}
63
+ showOwner
64
+ highlightId={highlightId}
65
+ onRevoke={actions.setPendingRevoke}
66
+ />
67
+ )}
68
+ </PageShell>
69
+
70
+ <CreateTokenDialog
71
+ open={creating}
72
+ onOpenChange={setCreating}
73
+ variant="service"
74
+ scopes={props.scopes}
75
+ roles={props.roles}
76
+ expiryPresets={props.expiry_presets}
77
+ busy={actions.busy}
78
+ onSubmit={submit}
79
+ />
80
+ <CreatedTokenDialog token={actions.created} onClose={actions.clearCreated} />
81
+ <RevokeTokenDialog
82
+ token={actions.pendingRevoke}
83
+ busy={actions.busy}
84
+ onCancel={() => actions.setPendingRevoke(null)}
85
+ onConfirm={actions.confirmRevoke}
86
+ />
87
+ </>
88
+ );
89
+ }
90
+
91
+ Admin.layout = [AdminLayout];
92
+ export default Admin;
@@ -0,0 +1,95 @@
1
+ import { Head, usePage } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActionDialog';
4
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
5
+ import { Button } from '@simple-module-py/ui/components/ui/button';
6
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
7
+ import { Ban, Plus } from 'lucide-react';
8
+ import { useState } from 'react';
9
+ import { CreatedTokenDialog } from './components/CreatedTokenDialog';
10
+ import { CreateTokenDialog } from './components/CreateTokenDialog';
11
+ import { RevokeTokenDialog } from './components/RevokeTokenDialog';
12
+ import { TokenTable } from './components/TokenTable';
13
+ import { useTokenActions } from './components/useTokenActions';
14
+ import { type CreatePayload, type TokenFormProps, tokenStatus } from './types';
15
+
16
+ /** Your own personal access tokens: list, create (secret shown once), revoke. */
17
+ function Index() {
18
+ const props = usePage<{ props: TokenFormProps }>().props as unknown as TokenFormProps;
19
+ const { t } = useT();
20
+ const [creating, setCreating] = useState(false);
21
+ const actions = useTokenActions({
22
+ csrfToken: props.csrf_token,
23
+ createUrl: `${props.api_url}/tokens/`,
24
+ revokeUrl: (id) => `${props.api_url}/tokens/${id}/revoke`,
25
+ revokeAllUrl: `${props.api_url}/tokens/revoke-all`,
26
+ });
27
+ const hasLive = props.tokens.some((tok) => tokenStatus(tok) === 'active');
28
+
29
+ async function submit(payload: CreatePayload) {
30
+ const ok = await actions.create(payload);
31
+ if (ok) setCreating(false);
32
+ return ok;
33
+ }
34
+
35
+ return (
36
+ <>
37
+ <Head title={t(keys.api_tokens.index.title)} />
38
+ <PageShell
39
+ title={t(keys.api_tokens.index.title)}
40
+ description={t(keys.api_tokens.index.description)}
41
+ actions={
42
+ <div className="flex gap-2">
43
+ <Button
44
+ variant="outline"
45
+ onClick={() => actions.setPendingRevokeAll(true)}
46
+ disabled={!hasLive}
47
+ >
48
+ <Ban className="size-4" />
49
+ {t(keys.api_tokens.revoke_all.button)}
50
+ </Button>
51
+ <Button onClick={() => setCreating(true)} disabled={props.scopes.length === 0}>
52
+ <Plus className="size-4" />
53
+ {t(keys.api_tokens.form.new_token)}
54
+ </Button>
55
+ </div>
56
+ }
57
+ >
58
+ <p className="text-sm text-muted-foreground">{t(keys.api_tokens.index.usage)}</p>
59
+ <TokenTable tokens={props.tokens} onRevoke={actions.setPendingRevoke} />
60
+ </PageShell>
61
+
62
+ <CreateTokenDialog
63
+ open={creating}
64
+ onOpenChange={setCreating}
65
+ variant="personal"
66
+ scopes={props.scopes}
67
+ expiryPresets={props.expiry_presets}
68
+ busy={actions.busy}
69
+ onSubmit={submit}
70
+ />
71
+ <CreatedTokenDialog token={actions.created} onClose={actions.clearCreated} />
72
+ <RevokeTokenDialog
73
+ token={actions.pendingRevoke}
74
+ busy={actions.busy}
75
+ onCancel={() => actions.setPendingRevoke(null)}
76
+ onConfirm={actions.confirmRevoke}
77
+ />
78
+ <ConfirmActionDialog
79
+ open={actions.pendingRevokeAll}
80
+ onOpenChange={(open) => !open && actions.setPendingRevokeAll(false)}
81
+ tone="destructive"
82
+ icon={Ban}
83
+ title={t(keys.api_tokens.revoke_all.title)}
84
+ description={t(keys.api_tokens.revoke_all.description)}
85
+ confirmLabel={t(keys.api_tokens.revoke_all.confirm)}
86
+ cancelLabel={t(keys.api_tokens.revoke_all.cancel)}
87
+ busy={actions.busy}
88
+ onConfirm={actions.confirmRevokeAll}
89
+ />
90
+ </>
91
+ );
92
+ }
93
+
94
+ Index.layout = [AuthenticatedLayout];
95
+ export default Index;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * JSON calls to the api_tokens endpoints.
3
+ *
4
+ * Plain `fetch`, not Inertia's router: creating a token must hand the page
5
+ * its secret as data, once, which a redirect cannot carry. Every mutation
6
+ * echoes the session's CSRF token (`RequiresCsrf` on the server).
7
+ */
8
+
9
+ export interface ApiResult<T> {
10
+ ok: boolean;
11
+ data: T | null;
12
+ /** The server's message when it sent a plain string; null otherwise. */
13
+ detail: string | null;
14
+ }
15
+
16
+ export async function postJson<T>(
17
+ url: string,
18
+ csrfToken: string,
19
+ body?: unknown,
20
+ ): Promise<ApiResult<T>> {
21
+ let response: Response;
22
+ try {
23
+ response = await fetch(url, {
24
+ method: 'POST',
25
+ credentials: 'same-origin',
26
+ headers: {
27
+ Accept: 'application/json',
28
+ 'Content-Type': 'application/json',
29
+ 'X-CSRF-Token': csrfToken,
30
+ },
31
+ body: body === undefined ? undefined : JSON.stringify(body),
32
+ });
33
+ } catch {
34
+ return { ok: false, data: null, detail: null };
35
+ }
36
+ const json = await response.json().catch(() => null);
37
+ if (!response.ok) {
38
+ const detail = json && typeof json.detail === 'string' ? json.detail : null;
39
+ return { ok: false, data: null, detail };
40
+ }
41
+ return { ok: true, data: json as T, detail: null };
42
+ }