supython 0.5.0__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.
- supython/__init__.py +8 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +149 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/body_size.py +184 -0
- supython/cli.py +1653 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +118 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- supython-0.5.0.dist-info/licenses/LICENSE +21 -0
supython/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
|
|
3
|
+
from .auth import router as auth_router
|
|
4
|
+
from .auth_templates import router as auth_templates_router
|
|
5
|
+
from .auth_users import router as auth_users_router
|
|
6
|
+
from .db import router as db_router
|
|
7
|
+
from .functions import router as functions_router
|
|
8
|
+
from .jobs import router as jobs_router
|
|
9
|
+
from .ops import router as ops_router
|
|
10
|
+
from .realtime import router as realtime_router
|
|
11
|
+
from .storage import router as storage_router
|
|
12
|
+
from .system import router as system_router
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
router.include_router(auth_router)
|
|
16
|
+
router.include_router(auth_templates_router)
|
|
17
|
+
router.include_router(auth_users_router)
|
|
18
|
+
router.include_router(system_router)
|
|
19
|
+
router.include_router(db_router)
|
|
20
|
+
router.include_router(storage_router)
|
|
21
|
+
router.include_router(functions_router)
|
|
22
|
+
router.include_router(jobs_router)
|
|
23
|
+
router.include_router(realtime_router)
|
|
24
|
+
router.include_router(ops_router)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
|
|
6
|
+
|
|
7
|
+
from ... import db
|
|
8
|
+
from .. import audit
|
|
9
|
+
from .. import session as admin_session
|
|
10
|
+
from ..errors import AdminError, to_http
|
|
11
|
+
from ..schemas import LoginRequest, SessionResponse
|
|
12
|
+
from . import service_auth
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/admin/api/v1/auth", tags=["admin.auth"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _client_ip(request: Request) -> str | None:
|
|
18
|
+
if request.client is None:
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
ipaddress.ip_address(request.client.host)
|
|
22
|
+
except ValueError:
|
|
23
|
+
return None
|
|
24
|
+
return request.client.host
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.post("/login", response_model=SessionResponse)
|
|
28
|
+
async def login(
|
|
29
|
+
payload: LoginRequest, request: Request, response: Response
|
|
30
|
+
) -> SessionResponse:
|
|
31
|
+
ip = _client_ip(request)
|
|
32
|
+
ua = request.headers.get("user-agent")
|
|
33
|
+
try:
|
|
34
|
+
async with db.as_service_role() as conn:
|
|
35
|
+
admin_id, email = await service_auth.authenticate(
|
|
36
|
+
conn, payload.email, payload.password
|
|
37
|
+
)
|
|
38
|
+
token, expires = await admin_session.issue(
|
|
39
|
+
conn, admin_id=admin_id, ip=ip, ua=ua
|
|
40
|
+
)
|
|
41
|
+
await service_auth.touch_last_login(conn, admin_id)
|
|
42
|
+
await audit.write(
|
|
43
|
+
conn,
|
|
44
|
+
admin_id=admin_id,
|
|
45
|
+
action="admin.login",
|
|
46
|
+
target=payload.email,
|
|
47
|
+
payload={"email": payload.email},
|
|
48
|
+
ip=ip,
|
|
49
|
+
ua=ua,
|
|
50
|
+
)
|
|
51
|
+
except AdminError as exc:
|
|
52
|
+
async with db.as_service_role() as audit_conn:
|
|
53
|
+
await audit.write(
|
|
54
|
+
audit_conn,
|
|
55
|
+
admin_id=None,
|
|
56
|
+
action="admin.login.failed",
|
|
57
|
+
target=payload.email,
|
|
58
|
+
payload={"email": payload.email, "error_code": exc.code},
|
|
59
|
+
ip=ip,
|
|
60
|
+
ua=ua,
|
|
61
|
+
)
|
|
62
|
+
raise to_http(exc) from exc
|
|
63
|
+
response.set_cookie(
|
|
64
|
+
admin_session.SESSION_COOKIE,
|
|
65
|
+
token,
|
|
66
|
+
max_age=int(admin_session.SESSION_TTL.total_seconds()),
|
|
67
|
+
httponly=True,
|
|
68
|
+
secure=True,
|
|
69
|
+
samesite="strict",
|
|
70
|
+
path=admin_session.SESSION_PATH,
|
|
71
|
+
)
|
|
72
|
+
return SessionResponse(admin_id=admin_id, email=email, expires_at=expires)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@router.post("/logout", status_code=204)
|
|
76
|
+
async def logout(
|
|
77
|
+
request: Request,
|
|
78
|
+
response: Response,
|
|
79
|
+
cookie: Annotated[str | None, Cookie(alias=admin_session.SESSION_COOKIE)] = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
if cookie:
|
|
82
|
+
ip = _client_ip(request)
|
|
83
|
+
ua = request.headers.get("user-agent")
|
|
84
|
+
async with db.as_service_role() as conn:
|
|
85
|
+
resolved = await admin_session.resolve(conn, cookie)
|
|
86
|
+
if resolved is not None:
|
|
87
|
+
admin_id, _expires = resolved
|
|
88
|
+
await audit.write(
|
|
89
|
+
conn,
|
|
90
|
+
admin_id=admin_id,
|
|
91
|
+
action="admin.logout",
|
|
92
|
+
target=None,
|
|
93
|
+
payload={},
|
|
94
|
+
ip=ip,
|
|
95
|
+
ua=ua,
|
|
96
|
+
)
|
|
97
|
+
await admin_session.revoke(conn, cookie)
|
|
98
|
+
response.delete_cookie(admin_session.SESSION_COOKIE, path=admin_session.SESSION_PATH)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.get("/session", response_model=SessionResponse)
|
|
102
|
+
async def session(
|
|
103
|
+
request: Request,
|
|
104
|
+
cookie: Annotated[str | None, Cookie(alias=admin_session.SESSION_COOKIE)] = None,
|
|
105
|
+
) -> SessionResponse:
|
|
106
|
+
if not cookie:
|
|
107
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Admin session required")
|
|
108
|
+
async with db.as_service_role() as conn:
|
|
109
|
+
resolved = await admin_session.resolve(conn, cookie)
|
|
110
|
+
if resolved is None:
|
|
111
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired session")
|
|
112
|
+
admin_id, expires = resolved
|
|
113
|
+
info = await service_auth.fetch_admin(conn, admin_id)
|
|
114
|
+
if info is None:
|
|
115
|
+
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Admin user not found")
|
|
116
|
+
email, _last_login = info
|
|
117
|
+
request.state.admin_id = admin_id
|
|
118
|
+
return SessionResponse(admin_id=admin_id, email=email, expires_at=expires)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, Request
|
|
5
|
+
|
|
6
|
+
from ... import db
|
|
7
|
+
from .. import audit
|
|
8
|
+
from ..deps import require_admin
|
|
9
|
+
from ..errors import AdminError, to_http
|
|
10
|
+
from ..schemas import EmailTemplate, EmailTemplateUpdate
|
|
11
|
+
from . import service_auth_templates
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/admin/api/v1/auth", tags=["admin.auth.templates"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/templates", response_model=list[EmailTemplate])
|
|
17
|
+
async def list_templates(
|
|
18
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
19
|
+
) -> list[EmailTemplate]:
|
|
20
|
+
try:
|
|
21
|
+
async with db.as_service_role() as conn:
|
|
22
|
+
return await service_auth_templates.list_templates(conn)
|
|
23
|
+
except AdminError as exc:
|
|
24
|
+
raise to_http(exc) from exc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/templates/{name}", response_model=EmailTemplate)
|
|
28
|
+
async def get_template(
|
|
29
|
+
name: str,
|
|
30
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
31
|
+
) -> EmailTemplate:
|
|
32
|
+
try:
|
|
33
|
+
async with db.as_service_role() as conn:
|
|
34
|
+
return await service_auth_templates.get_template(conn, name)
|
|
35
|
+
except AdminError as exc:
|
|
36
|
+
raise to_http(exc) from exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.patch("/templates/{name}", response_model=EmailTemplate)
|
|
40
|
+
async def update_template(
|
|
41
|
+
name: str,
|
|
42
|
+
payload: EmailTemplateUpdate,
|
|
43
|
+
request: Request,
|
|
44
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
45
|
+
) -> EmailTemplate:
|
|
46
|
+
try:
|
|
47
|
+
async with db.as_service_role() as conn:
|
|
48
|
+
result = await service_auth_templates.update_template(
|
|
49
|
+
conn,
|
|
50
|
+
name,
|
|
51
|
+
payload.subject,
|
|
52
|
+
payload.text_body,
|
|
53
|
+
)
|
|
54
|
+
except AdminError as exc:
|
|
55
|
+
raise to_http(exc) from exc
|
|
56
|
+
|
|
57
|
+
async with db.as_service_role() as conn:
|
|
58
|
+
await audit.write(
|
|
59
|
+
conn,
|
|
60
|
+
admin_id=admin_id,
|
|
61
|
+
action="auth.template.update",
|
|
62
|
+
target=name,
|
|
63
|
+
payload=payload.model_dump(exclude_none=True),
|
|
64
|
+
ip=request.client.host if request.client else None,
|
|
65
|
+
ua=request.headers.get("user-agent"),
|
|
66
|
+
)
|
|
67
|
+
return result
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Body, Depends, Request
|
|
6
|
+
|
|
7
|
+
from ... import db
|
|
8
|
+
from .. import audit
|
|
9
|
+
from ..deps import require_admin
|
|
10
|
+
from ..errors import AdminError, to_http
|
|
11
|
+
from ..schemas import (
|
|
12
|
+
AdminAuditPage,
|
|
13
|
+
AdminUserDetail,
|
|
14
|
+
AdminUsersPage,
|
|
15
|
+
BanRequest,
|
|
16
|
+
RefreshTokensPage,
|
|
17
|
+
)
|
|
18
|
+
from . import service_auth_users
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/admin/api/v1/auth", tags=["admin.auth.users"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _client_ip(request: Request) -> str | None:
|
|
24
|
+
if request.client is None:
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
ipaddress.ip_address(request.client.host)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return None
|
|
30
|
+
return request.client.host
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.get("/users", response_model=AdminUsersPage)
|
|
34
|
+
async def list_users(
|
|
35
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
36
|
+
search: str | None = None,
|
|
37
|
+
confirmed: bool | None = None,
|
|
38
|
+
banned: bool | None = None,
|
|
39
|
+
limit: int = 50,
|
|
40
|
+
offset: int = 0,
|
|
41
|
+
) -> AdminUsersPage:
|
|
42
|
+
try:
|
|
43
|
+
async with db.as_service_role() as conn:
|
|
44
|
+
return await service_auth_users.search_users(
|
|
45
|
+
conn, search, confirmed, banned, limit, offset
|
|
46
|
+
)
|
|
47
|
+
except AdminError as exc:
|
|
48
|
+
raise to_http(exc) from exc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("/users/{user_id}", response_model=AdminUserDetail)
|
|
52
|
+
async def get_user(
|
|
53
|
+
user_id: UUID,
|
|
54
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
55
|
+
) -> AdminUserDetail:
|
|
56
|
+
try:
|
|
57
|
+
async with db.as_service_role() as conn:
|
|
58
|
+
return await service_auth_users.get_user_detail(conn, user_id)
|
|
59
|
+
except AdminError as exc:
|
|
60
|
+
raise to_http(exc) from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("/users/{user_id}/ban")
|
|
64
|
+
async def ban_user(
|
|
65
|
+
user_id: UUID,
|
|
66
|
+
request: Request,
|
|
67
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
68
|
+
payload: Annotated[BanRequest | None, Body()] = None,
|
|
69
|
+
) -> dict[str, str]:
|
|
70
|
+
ip = _client_ip(request)
|
|
71
|
+
ua = request.headers.get("user-agent")
|
|
72
|
+
duration = payload.duration_seconds if payload else None
|
|
73
|
+
try:
|
|
74
|
+
async with db.as_service_role() as conn:
|
|
75
|
+
banned_until = await service_auth_users.ban_user(conn, user_id, duration)
|
|
76
|
+
await service_auth_users.write_user_audit(
|
|
77
|
+
conn,
|
|
78
|
+
user_id=user_id,
|
|
79
|
+
event="user.banned",
|
|
80
|
+
payload={"banned_until": banned_until.isoformat()},
|
|
81
|
+
ip=ip,
|
|
82
|
+
ua=ua,
|
|
83
|
+
)
|
|
84
|
+
await audit.write(
|
|
85
|
+
conn,
|
|
86
|
+
admin_id=admin_id,
|
|
87
|
+
action="auth.user.ban",
|
|
88
|
+
target=str(user_id),
|
|
89
|
+
payload={"banned_until": banned_until.isoformat()},
|
|
90
|
+
ip=ip,
|
|
91
|
+
ua=ua,
|
|
92
|
+
)
|
|
93
|
+
except AdminError as exc:
|
|
94
|
+
raise to_http(exc) from exc
|
|
95
|
+
return {"banned_until": banned_until.isoformat()}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.post("/users/{user_id}/unban", status_code=204)
|
|
99
|
+
async def unban_user(
|
|
100
|
+
user_id: UUID,
|
|
101
|
+
request: Request,
|
|
102
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
103
|
+
) -> None:
|
|
104
|
+
ip = _client_ip(request)
|
|
105
|
+
ua = request.headers.get("user-agent")
|
|
106
|
+
try:
|
|
107
|
+
async with db.as_service_role() as conn:
|
|
108
|
+
await service_auth_users.unban_user(conn, user_id)
|
|
109
|
+
await service_auth_users.write_user_audit(
|
|
110
|
+
conn,
|
|
111
|
+
user_id=user_id,
|
|
112
|
+
event="user.unbanned",
|
|
113
|
+
payload={},
|
|
114
|
+
ip=ip,
|
|
115
|
+
ua=ua,
|
|
116
|
+
)
|
|
117
|
+
await audit.write(
|
|
118
|
+
conn,
|
|
119
|
+
admin_id=admin_id,
|
|
120
|
+
action="auth.user.unban",
|
|
121
|
+
target=str(user_id),
|
|
122
|
+
payload={},
|
|
123
|
+
ip=ip,
|
|
124
|
+
ua=ua,
|
|
125
|
+
)
|
|
126
|
+
except AdminError as exc:
|
|
127
|
+
raise to_http(exc) from exc
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.post("/users/{user_id}/force-logout")
|
|
131
|
+
async def force_logout(
|
|
132
|
+
user_id: UUID,
|
|
133
|
+
request: Request,
|
|
134
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
135
|
+
) -> dict[str, int]:
|
|
136
|
+
ip = _client_ip(request)
|
|
137
|
+
ua = request.headers.get("user-agent")
|
|
138
|
+
try:
|
|
139
|
+
async with db.as_service_role() as conn:
|
|
140
|
+
revoked = await service_auth_users.force_logout(conn, user_id)
|
|
141
|
+
await service_auth_users.write_user_audit(
|
|
142
|
+
conn,
|
|
143
|
+
user_id=user_id,
|
|
144
|
+
event="user.force_logout",
|
|
145
|
+
payload={"revoked": revoked},
|
|
146
|
+
ip=ip,
|
|
147
|
+
ua=ua,
|
|
148
|
+
)
|
|
149
|
+
await audit.write(
|
|
150
|
+
conn,
|
|
151
|
+
admin_id=admin_id,
|
|
152
|
+
action="auth.user.force_logout",
|
|
153
|
+
target=str(user_id),
|
|
154
|
+
payload={"revoked": revoked},
|
|
155
|
+
ip=ip,
|
|
156
|
+
ua=ua,
|
|
157
|
+
)
|
|
158
|
+
except AdminError as exc:
|
|
159
|
+
raise to_http(exc) from exc
|
|
160
|
+
return {"revoked": revoked}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@router.get("/refresh-tokens", response_model=RefreshTokensPage)
|
|
164
|
+
async def list_refresh_tokens(
|
|
165
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
166
|
+
user_id: UUID | None = None,
|
|
167
|
+
limit: int = 50,
|
|
168
|
+
offset: int = 0,
|
|
169
|
+
) -> RefreshTokensPage:
|
|
170
|
+
try:
|
|
171
|
+
async with db.as_service_role() as conn:
|
|
172
|
+
return await service_auth_users.list_refresh_tokens(conn, user_id, limit, offset)
|
|
173
|
+
except AdminError as exc:
|
|
174
|
+
raise to_http(exc) from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@router.delete("/refresh-tokens/{token_id}", status_code=204)
|
|
178
|
+
async def revoke_refresh_token(
|
|
179
|
+
token_id: int,
|
|
180
|
+
request: Request,
|
|
181
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
182
|
+
) -> None:
|
|
183
|
+
ip = _client_ip(request)
|
|
184
|
+
ua = request.headers.get("user-agent")
|
|
185
|
+
try:
|
|
186
|
+
async with db.as_service_role() as conn:
|
|
187
|
+
user_id = await service_auth_users.revoke_refresh_token(conn, token_id)
|
|
188
|
+
await service_auth_users.write_user_audit(
|
|
189
|
+
conn,
|
|
190
|
+
user_id=user_id,
|
|
191
|
+
event="refresh_token.revoked",
|
|
192
|
+
payload={"token_id": token_id},
|
|
193
|
+
ip=ip,
|
|
194
|
+
ua=ua,
|
|
195
|
+
)
|
|
196
|
+
await audit.write(
|
|
197
|
+
conn,
|
|
198
|
+
admin_id=admin_id,
|
|
199
|
+
action="auth.refresh_token.revoke",
|
|
200
|
+
target=str(token_id),
|
|
201
|
+
payload={"token_id": token_id, "user_id": str(user_id)},
|
|
202
|
+
ip=ip,
|
|
203
|
+
ua=ua,
|
|
204
|
+
)
|
|
205
|
+
except AdminError as exc:
|
|
206
|
+
raise to_http(exc) from exc
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@router.get("/audit", response_model=AdminAuditPage)
|
|
210
|
+
async def list_audit(
|
|
211
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
212
|
+
event: str | None = None,
|
|
213
|
+
ip: str | None = None,
|
|
214
|
+
from_date: str | None = None,
|
|
215
|
+
to_date: str | None = None,
|
|
216
|
+
limit: int = 50,
|
|
217
|
+
offset: int = 0,
|
|
218
|
+
) -> AdminAuditPage:
|
|
219
|
+
try:
|
|
220
|
+
async with db.as_service_role() as conn:
|
|
221
|
+
return await service_auth_users.list_audit_log(
|
|
222
|
+
conn, event, ip, from_date, to_date, limit, offset
|
|
223
|
+
)
|
|
224
|
+
except AdminError as exc:
|
|
225
|
+
raise to_http(exc) from exc
|
supython/admin/api/db.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
from typing import Annotated, Literal
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Body, Depends, Request
|
|
6
|
+
|
|
7
|
+
from ... import db
|
|
8
|
+
from .. import audit
|
|
9
|
+
from ..deps import require_admin
|
|
10
|
+
from ..errors import AdminError, to_http
|
|
11
|
+
from ..schemas import (
|
|
12
|
+
DryRunRequest,
|
|
13
|
+
DryRunResponse,
|
|
14
|
+
MigrationRecord,
|
|
15
|
+
RlsPolicy,
|
|
16
|
+
RowsPage,
|
|
17
|
+
SchemaInfo,
|
|
18
|
+
SqlExecRequest,
|
|
19
|
+
SqlExecResponse,
|
|
20
|
+
TableInfo,
|
|
21
|
+
)
|
|
22
|
+
from . import service_db
|
|
23
|
+
|
|
24
|
+
router = APIRouter(prefix="/admin/api/v1/db", tags=["admin.db"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _client_ip(request: Request) -> str | None:
|
|
28
|
+
if request.client is None:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
ipaddress.ip_address(request.client.host)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return None
|
|
34
|
+
return request.client.host
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/schemas", response_model=list[SchemaInfo])
|
|
38
|
+
async def list_schemas(
|
|
39
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
40
|
+
) -> list[SchemaInfo]:
|
|
41
|
+
async with db.as_service_role() as conn:
|
|
42
|
+
return await service_db.list_schemas(conn)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.get("/tables/{schema}", response_model=list[TableInfo])
|
|
46
|
+
async def list_tables(
|
|
47
|
+
schema: str,
|
|
48
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
49
|
+
) -> list[TableInfo]:
|
|
50
|
+
try:
|
|
51
|
+
async with db.as_service_role() as conn:
|
|
52
|
+
return await service_db.list_tables(conn, schema)
|
|
53
|
+
except AdminError as exc:
|
|
54
|
+
raise to_http(exc) from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/tables/{schema}/{table}/rows", response_model=RowsPage)
|
|
58
|
+
async def read_rows(
|
|
59
|
+
schema: str,
|
|
60
|
+
table: str,
|
|
61
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
62
|
+
limit: int = 50,
|
|
63
|
+
offset: int = 0,
|
|
64
|
+
order: str | None = None,
|
|
65
|
+
role: Literal["service_role", "authenticated", "anon"] = "service_role",
|
|
66
|
+
impersonate_sub: UUID | None = None,
|
|
67
|
+
) -> RowsPage:
|
|
68
|
+
try:
|
|
69
|
+
if role == "service_role":
|
|
70
|
+
async with db.as_service_role() as conn:
|
|
71
|
+
return await service_db.read_rows(conn, schema, table, limit, offset, order)
|
|
72
|
+
claims = service_db.preview_claims(role, impersonate_sub)
|
|
73
|
+
async with db.as_role(role, claims) as conn:
|
|
74
|
+
return await service_db.read_rows(conn, schema, table, limit, offset, order)
|
|
75
|
+
except AdminError as exc:
|
|
76
|
+
raise to_http(exc) from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.post("/sql/execute", response_model=SqlExecResponse)
|
|
80
|
+
async def run_sql(
|
|
81
|
+
request: Request,
|
|
82
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
83
|
+
payload: Annotated[SqlExecRequest, Body()],
|
|
84
|
+
) -> SqlExecResponse:
|
|
85
|
+
ip = _client_ip(request)
|
|
86
|
+
ua = request.headers.get("user-agent")
|
|
87
|
+
try:
|
|
88
|
+
async with db.as_service_role() as conn:
|
|
89
|
+
try:
|
|
90
|
+
result = await service_db.execute_sql(conn, payload)
|
|
91
|
+
except AdminError as exc:
|
|
92
|
+
# Audit the failed attempt on a fresh connection so the poisoned
|
|
93
|
+
# transaction is not reused.
|
|
94
|
+
async with db.as_service_role() as audit_conn:
|
|
95
|
+
await audit.write(
|
|
96
|
+
audit_conn,
|
|
97
|
+
admin_id=admin_id,
|
|
98
|
+
action="sql.execute.failed",
|
|
99
|
+
target=None,
|
|
100
|
+
payload={
|
|
101
|
+
"statement": payload.statement[:2048],
|
|
102
|
+
"error_code": exc.code,
|
|
103
|
+
},
|
|
104
|
+
ip=ip,
|
|
105
|
+
ua=ua,
|
|
106
|
+
)
|
|
107
|
+
raise
|
|
108
|
+
await audit.write(
|
|
109
|
+
conn,
|
|
110
|
+
admin_id=admin_id,
|
|
111
|
+
action="sql.execute",
|
|
112
|
+
target=None,
|
|
113
|
+
payload={
|
|
114
|
+
"statement": payload.statement[:2048],
|
|
115
|
+
"row_count": result.row_count,
|
|
116
|
+
},
|
|
117
|
+
ip=ip,
|
|
118
|
+
ua=ua,
|
|
119
|
+
)
|
|
120
|
+
return result
|
|
121
|
+
except AdminError as exc:
|
|
122
|
+
raise to_http(exc) from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.get("/rls/{schema}/{table}", response_model=list[RlsPolicy])
|
|
126
|
+
async def list_policies(
|
|
127
|
+
schema: str,
|
|
128
|
+
table: str,
|
|
129
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
130
|
+
) -> list[RlsPolicy]:
|
|
131
|
+
try:
|
|
132
|
+
async with db.as_service_role() as conn:
|
|
133
|
+
return await service_db.list_policies(conn, schema, table)
|
|
134
|
+
except AdminError as exc:
|
|
135
|
+
raise to_http(exc) from exc
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@router.post("/rls/dry-run", response_model=DryRunResponse)
|
|
139
|
+
async def dry_run_policy(
|
|
140
|
+
request: Request,
|
|
141
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
142
|
+
payload: Annotated[DryRunRequest, Body()],
|
|
143
|
+
) -> DryRunResponse:
|
|
144
|
+
ip = _client_ip(request)
|
|
145
|
+
ua = request.headers.get("user-agent")
|
|
146
|
+
try:
|
|
147
|
+
async with db.as_service_role() as conn:
|
|
148
|
+
result = await service_db.dry_run_policy(conn, payload.ddl, payload.sample_query)
|
|
149
|
+
await audit.write(
|
|
150
|
+
conn,
|
|
151
|
+
admin_id=admin_id,
|
|
152
|
+
action="rls.dry_run",
|
|
153
|
+
target=None,
|
|
154
|
+
payload={
|
|
155
|
+
"ddl": payload.ddl[:2048],
|
|
156
|
+
"sample_query": payload.sample_query[:2048],
|
|
157
|
+
},
|
|
158
|
+
ip=ip,
|
|
159
|
+
ua=ua,
|
|
160
|
+
)
|
|
161
|
+
return result
|
|
162
|
+
except AdminError as exc:
|
|
163
|
+
raise to_http(exc) from exc
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.get("/migrations", response_model=list[MigrationRecord])
|
|
167
|
+
async def list_migrations(
|
|
168
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
169
|
+
) -> list[MigrationRecord]:
|
|
170
|
+
try:
|
|
171
|
+
async with db.as_service_role() as conn:
|
|
172
|
+
return await service_db.list_migrations(conn)
|
|
173
|
+
except AdminError as exc:
|
|
174
|
+
raise to_http(exc) from exc
|