interloper-api 0.99.0__tar.gz → 0.101.0__tar.gz
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.
- {interloper_api-0.99.0 → interloper_api-0.101.0}/PKG-INFO +1 -1
- {interloper_api-0.99.0 → interloper_api-0.101.0}/pyproject.toml +12 -1
- {interloper_api-0.99.0 → interloper_api-0.101.0}/pyproject.toml.orig +10 -3
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/app.py +58 -39
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/dependencies/__init__.py +4 -0
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/dependencies/auth.py +21 -42
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/dependencies/rbac.py +25 -7
- interloper_api-0.101.0/src/interloper_api/notifications/__init__.py +7 -0
- interloper_api-0.99.0/src/interloper_api/notifications/invitations.py → interloper_api-0.101.0/src/interloper_api/notifications/base.py +127 -51
- interloper_api-0.101.0/src/interloper_api/notifications/invitations.py +100 -0
- interloper_api-0.101.0/src/interloper_api/notifications/super_admins.py +110 -0
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/admin.py +180 -487
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/agent.py +11 -8
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/auth.py +71 -106
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/backfills.py +39 -65
- interloper_api-0.101.0/src/interloper_api/routes/catalog.py +316 -0
- interloper_api-0.101.0/src/interloper_api/routes/components.py +537 -0
- interloper_api-0.101.0/src/interloper_api/routes/executions.py +76 -0
- interloper_api-0.101.0/src/interloper_api/routes/organisations.py +448 -0
- interloper_api-0.101.0/src/interloper_api/routes/overview.py +283 -0
- interloper_api-0.101.0/src/interloper_api/routes/relations.py +73 -0
- interloper_api-0.101.0/src/interloper_api/routes/runs.py +331 -0
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/tokens.py +16 -15
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/websocket.py +3 -4
- interloper_api-0.101.0/src/interloper_api/utils/__init__.py +5 -0
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/utils/time.py +0 -17
- interloper_api-0.99.0/src/interloper_api/notifications/__init__.py +0 -5
- interloper_api-0.99.0/src/interloper_api/routes/catalog.py +0 -83
- interloper_api-0.99.0/src/interloper_api/routes/components.py +0 -904
- interloper_api-0.99.0/src/interloper_api/routes/organisations.py +0 -384
- interloper_api-0.99.0/src/interloper_api/routes/overview.py +0 -997
- interloper_api-0.99.0/src/interloper_api/routes/runs.py +0 -495
- interloper_api-0.99.0/src/interloper_api/utils/__init__.py +0 -5
- {interloper_api-0.99.0 → interloper_api-0.101.0}/README.md +0 -0
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/dependencies/state.py +0 -0
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/health.py +0 -0
- {interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/routes/oauth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "interloper-api"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.101.0"
|
|
4
4
|
description = "Interloper FastAPI routes"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -53,6 +53,7 @@ extend-select = [
|
|
|
53
53
|
"ANN202",
|
|
54
54
|
"DOC",
|
|
55
55
|
"D",
|
|
56
|
+
"TID251",
|
|
56
57
|
]
|
|
57
58
|
|
|
58
59
|
[tool.ruff.lint.pydocstyle]
|
|
@@ -71,12 +72,22 @@ extend-immutable-calls = [
|
|
|
71
72
|
"fastapi.Security",
|
|
72
73
|
]
|
|
73
74
|
|
|
75
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api.sqlalchemy]
|
|
76
|
+
msg = "Database access goes through interloper_db.Store facets"
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api.sqlmodel]
|
|
79
|
+
msg = "Database access goes through interloper_db.Store facets"
|
|
80
|
+
|
|
81
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api."interloper_db.session"]
|
|
82
|
+
msg = "Database access goes through interloper_db.Store facets"
|
|
83
|
+
|
|
74
84
|
[tool.ruff.lint.per-file-ignores]
|
|
75
85
|
"__init__.py" = [
|
|
76
86
|
"F401",
|
|
77
87
|
"F403",
|
|
78
88
|
]
|
|
79
89
|
"tests/**" = [
|
|
90
|
+
"TID251",
|
|
80
91
|
"ANN",
|
|
81
92
|
"F811",
|
|
82
93
|
"D101",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# ###############
|
|
4
4
|
[project]
|
|
5
5
|
name = "interloper-api"
|
|
6
|
-
version = "0.
|
|
6
|
+
version = "0.101.0"
|
|
7
7
|
description = "Interloper FastAPI routes"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
|
|
@@ -43,7 +43,7 @@ line-length = 120
|
|
|
43
43
|
|
|
44
44
|
[tool.ruff.lint]
|
|
45
45
|
preview = true
|
|
46
|
-
extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202", "DOC", "D"]
|
|
46
|
+
extend-select = ["E", "I", "UP", "ANN001", "ANN201", "ANN202", "DOC", "D", "TID251"]
|
|
47
47
|
|
|
48
48
|
[tool.ruff.lint.pydocstyle]
|
|
49
49
|
convention = "google"
|
|
@@ -63,6 +63,13 @@ extend-immutable-calls = [
|
|
|
63
63
|
"fastapi.Security",
|
|
64
64
|
]
|
|
65
65
|
|
|
66
|
+
# Raw database access lives in interloper-db alone; this package reads and
|
|
67
|
+
# writes through the store's facets.
|
|
68
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
|
69
|
+
"sqlalchemy".msg = "Database access goes through interloper_db.Store facets"
|
|
70
|
+
"sqlmodel".msg = "Database access goes through interloper_db.Store facets"
|
|
71
|
+
"interloper_db.session".msg = "Database access goes through interloper_db.Store facets"
|
|
72
|
+
|
|
66
73
|
[tool.ruff.lint.per-file-ignores]
|
|
67
74
|
"__init__.py" = ["F401", "F403"]
|
|
68
|
-
"tests/**" = ["ANN", "F811", "D101", "D102", "D103", "D104", "D107", "RUF069", "PLW0108"]
|
|
75
|
+
"tests/**" = ["TID251", "ANN", "F811", "D101", "D102", "D103", "D104", "D107", "RUF069", "PLW0108"]
|
|
@@ -10,7 +10,15 @@ from fastapi import APIRouter, FastAPI, Request
|
|
|
10
10
|
from fastapi.middleware.cors import CORSMiddleware
|
|
11
11
|
from fastapi.responses import JSONResponse
|
|
12
12
|
from interloper.catalog.base import Catalog
|
|
13
|
-
from interloper.errors import
|
|
13
|
+
from interloper.errors import (
|
|
14
|
+
ComponentDriftError,
|
|
15
|
+
ConfigError,
|
|
16
|
+
ConflictError,
|
|
17
|
+
HydrationError,
|
|
18
|
+
InUseError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
QuotaExceededError,
|
|
21
|
+
)
|
|
14
22
|
from interloper_db import Store
|
|
15
23
|
|
|
16
24
|
from interloper_api.dependencies import (
|
|
@@ -28,10 +36,12 @@ from interloper_api.routes import (
|
|
|
28
36
|
auth,
|
|
29
37
|
backfills,
|
|
30
38
|
components,
|
|
39
|
+
executions,
|
|
31
40
|
health,
|
|
32
41
|
oauth,
|
|
33
42
|
organisations,
|
|
34
43
|
overview,
|
|
44
|
+
relations,
|
|
35
45
|
runs,
|
|
36
46
|
tokens,
|
|
37
47
|
websocket,
|
|
@@ -48,7 +58,9 @@ _ROUTE_MODULES: tuple[ModuleType, ...] = (
|
|
|
48
58
|
admin,
|
|
49
59
|
catalog_routes,
|
|
50
60
|
components,
|
|
61
|
+
relations,
|
|
51
62
|
runs,
|
|
63
|
+
executions,
|
|
52
64
|
backfills,
|
|
53
65
|
overview,
|
|
54
66
|
oauth,
|
|
@@ -61,52 +73,48 @@ _ROUTE_MODULES: tuple[ModuleType, ...] = (
|
|
|
61
73
|
# -- Error handling ------------------------------------------------------------
|
|
62
74
|
|
|
63
75
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
#: Framework errors that have an HTTP meaning, by the status each answers.
|
|
77
|
+
#: Drift and an unhydratable record are 409s: the record stays broken until a
|
|
78
|
+
#: user repoints, re-keys or rewrites it, so the reason is surfaced instead of
|
|
79
|
+
#: an opaque 500. Anything absent here is a bug and stays a 500.
|
|
80
|
+
_ERROR_STATUS: dict[type[Exception], int] = {
|
|
81
|
+
ConfigError: 400,
|
|
82
|
+
NotFoundError: 404,
|
|
83
|
+
ConflictError: 409,
|
|
84
|
+
ComponentDriftError: 409,
|
|
85
|
+
HydrationError: 409,
|
|
86
|
+
}
|
|
76
87
|
|
|
77
|
-
async def _component_drift(_request: Request, exception: ComponentDriftError) -> JSONResponse:
|
|
78
|
-
"""Render catalog drift as a conflict rather than a 500.
|
|
79
88
|
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
async def _plain_error(_request: Request, exception: Exception) -> JSONResponse:
|
|
90
|
+
"""Render a framework error as its mapped status, the message as ``detail``.
|
|
82
91
|
|
|
83
92
|
Args:
|
|
84
93
|
_request: The incoming request, unused.
|
|
85
|
-
exception: The raised
|
|
94
|
+
exception: The raised error, an instance of exactly one key of
|
|
95
|
+
:data:`_ERROR_STATUS` (none of the keys subclasses another).
|
|
86
96
|
|
|
87
97
|
Returns:
|
|
88
|
-
|
|
98
|
+
The response, at the status mapped to the error's class.
|
|
89
99
|
"""
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
status = next(status for error_type, status in _ERROR_STATUS.items() if isinstance(exception, error_type))
|
|
101
|
+
return JSONResponse(status_code=status, content={"detail": str(exception)})
|
|
92
102
|
|
|
93
|
-
async def _hydration_failed(_request: Request, exception: HydrationError) -> JSONResponse:
|
|
94
|
-
"""Render an unreadable stored record as a conflict rather than a 500.
|
|
95
103
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
longer accepts) stays broken until an operator re-keys or rewrites it, so
|
|
99
|
-
like drift it surfaces as a 409 carrying the reason instead of an opaque
|
|
100
|
-
500 that only the server log explains.
|
|
104
|
+
async def _in_use(_request: Request, exception: InUseError) -> JSONResponse:
|
|
105
|
+
"""Render a refused deletion as a 409 naming what still depends on the record.
|
|
101
106
|
|
|
102
107
|
Args:
|
|
103
108
|
_request: The incoming request, unused.
|
|
104
|
-
exception: The raised :class:`
|
|
109
|
+
exception: The raised :class:`InUseError`.
|
|
105
110
|
|
|
106
111
|
Returns:
|
|
107
|
-
A 409 response
|
|
112
|
+
A 409 response whose ``detail`` carries the message and the referrers
|
|
113
|
+
as ``used_by``.
|
|
108
114
|
"""
|
|
109
|
-
return JSONResponse(
|
|
115
|
+
return JSONResponse(
|
|
116
|
+
status_code=409, content={"detail": {"message": str(exception), "used_by": exception.referrers}}
|
|
117
|
+
)
|
|
110
118
|
|
|
111
119
|
|
|
112
120
|
async def _quota_exceeded(_request: Request, exception: QuotaExceededError) -> JSONResponse:
|
|
@@ -133,16 +141,28 @@ async def _quota_exceeded(_request: Request, exception: QuotaExceededError) -> J
|
|
|
133
141
|
)
|
|
134
142
|
|
|
135
143
|
|
|
136
|
-
#:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
NotFoundError: _not_found,
|
|
140
|
-
ComponentDriftError: _component_drift,
|
|
141
|
-
HydrationError: _hydration_failed,
|
|
144
|
+
#: Errors whose response carries more than the message.
|
|
145
|
+
_STRUCTURED_ERRORS: dict[type[Exception], Any] = {
|
|
146
|
+
InUseError: _in_use,
|
|
142
147
|
QuotaExceededError: _quota_exceeded,
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
|
|
151
|
+
def install_error_handlers(app: FastAPI) -> None:
|
|
152
|
+
"""Answer every framework error that has an HTTP meaning with its status.
|
|
153
|
+
|
|
154
|
+
Routes raise the store's errors as they come; this one mapping turns them
|
|
155
|
+
into responses, so no handler translates an exception by hand.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
app: The application to install the handlers on.
|
|
159
|
+
"""
|
|
160
|
+
for error_type in _ERROR_STATUS:
|
|
161
|
+
app.add_exception_handler(error_type, _plain_error)
|
|
162
|
+
for error_type, handler in _STRUCTURED_ERRORS.items():
|
|
163
|
+
app.add_exception_handler(error_type, handler)
|
|
164
|
+
|
|
165
|
+
|
|
146
166
|
# -- Application factory -------------------------------------------------------
|
|
147
167
|
|
|
148
168
|
|
|
@@ -172,8 +192,7 @@ def create_app(
|
|
|
172
192
|
"""
|
|
173
193
|
app = FastAPI(title="Interloper API", lifespan=websocket.realtime_lifespan, **kwargs)
|
|
174
194
|
|
|
175
|
-
|
|
176
|
-
app.add_exception_handler(error_type, handler)
|
|
195
|
+
install_error_handlers(app)
|
|
177
196
|
|
|
178
197
|
if cors_origins:
|
|
179
198
|
app.add_middleware(
|
{interloper_api-0.99.0 → interloper_api-0.101.0}/src/interloper_api/dependencies/__init__.py
RENAMED
|
@@ -8,6 +8,7 @@ organisation per request; ``rbac`` gates handlers on the caller's role.
|
|
|
8
8
|
from interloper_api.dependencies.auth import (
|
|
9
9
|
CurrentUserDep,
|
|
10
10
|
OrgIdDep,
|
|
11
|
+
SessionContextDep,
|
|
11
12
|
get_current_org,
|
|
12
13
|
get_current_user,
|
|
13
14
|
get_org_id,
|
|
@@ -19,6 +20,7 @@ from interloper_api.dependencies.rbac import (
|
|
|
19
20
|
SuperAdminDep,
|
|
20
21
|
ViewerDep,
|
|
21
22
|
authorize_org_member,
|
|
23
|
+
authorize_organisation,
|
|
22
24
|
load_authorized,
|
|
23
25
|
require_admin,
|
|
24
26
|
require_editor,
|
|
@@ -60,10 +62,12 @@ __all__ = [
|
|
|
60
62
|
"EditorDep",
|
|
61
63
|
"OrgIdDep",
|
|
62
64
|
"QuotaDefaultsDep",
|
|
65
|
+
"SessionContextDep",
|
|
63
66
|
"StoreDep",
|
|
64
67
|
"SuperAdminDep",
|
|
65
68
|
"ViewerDep",
|
|
66
69
|
"authorize_org_member",
|
|
70
|
+
"authorize_organisation",
|
|
67
71
|
"get_admin_config",
|
|
68
72
|
"get_agent",
|
|
69
73
|
"get_auth_config",
|
|
@@ -12,86 +12,64 @@ from interloper_db.models import AuthSession
|
|
|
12
12
|
from interloper_api.dependencies.state import get_store
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def
|
|
15
|
+
def get_session_context(
|
|
16
16
|
store: Store = Depends(get_store),
|
|
17
17
|
session_token: str | None = Cookie(default=None),
|
|
18
|
-
) -> Profile:
|
|
19
|
-
"""Resolve the
|
|
18
|
+
) -> tuple[Profile, AuthSession]:
|
|
19
|
+
"""Resolve the caller and their login session from the session cookie.
|
|
20
20
|
|
|
21
21
|
Args:
|
|
22
22
|
store: The Store instance.
|
|
23
23
|
session_token: Session cookie value.
|
|
24
24
|
|
|
25
25
|
Returns:
|
|
26
|
-
|
|
26
|
+
``(Profile, AuthSession)``.
|
|
27
27
|
|
|
28
28
|
Raises:
|
|
29
|
-
HTTPException: 401 if not authenticated or session invalid
|
|
29
|
+
HTTPException: 401 if not authenticated or the session is invalid or expired.
|
|
30
30
|
"""
|
|
31
31
|
if not session_token:
|
|
32
32
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
33
|
-
|
|
34
|
-
result = store.auth.resolve_session(session_token)
|
|
33
|
+
result = store.sessions.resolve(session_token)
|
|
35
34
|
if not result:
|
|
36
35
|
raise HTTPException(status_code=401, detail="Invalid or expired session")
|
|
37
|
-
|
|
38
|
-
profile, _ = result
|
|
39
|
-
return profile
|
|
36
|
+
return result
|
|
40
37
|
|
|
41
38
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"""Resolve user and session from the cookie.
|
|
39
|
+
def get_current_user(
|
|
40
|
+
context: tuple[Profile, AuthSession] = Depends(get_session_context),
|
|
41
|
+
) -> Profile:
|
|
42
|
+
"""Resolve the current user from the session.
|
|
47
43
|
|
|
48
44
|
Args:
|
|
49
|
-
|
|
50
|
-
session_token: Session cookie value.
|
|
45
|
+
context: The caller and their session.
|
|
51
46
|
|
|
52
47
|
Returns:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
Raises:
|
|
56
|
-
HTTPException: 401 if not authenticated.
|
|
48
|
+
The authenticated Profile.
|
|
57
49
|
"""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
result = store.auth.resolve_session(session_token)
|
|
62
|
-
if not result:
|
|
63
|
-
raise HTTPException(status_code=401, detail="Invalid or expired session")
|
|
64
|
-
|
|
65
|
-
return result
|
|
50
|
+
profile, _ = context
|
|
51
|
+
return profile
|
|
66
52
|
|
|
67
53
|
|
|
68
54
|
def get_current_org(
|
|
55
|
+
context: tuple[Profile, AuthSession] = Depends(get_session_context),
|
|
69
56
|
store: Store = Depends(get_store),
|
|
70
|
-
session_token: str | None = Cookie(default=None),
|
|
71
57
|
) -> Organisation:
|
|
72
|
-
"""Resolve the
|
|
58
|
+
"""Resolve the organisation the session is working in.
|
|
73
59
|
|
|
74
60
|
Args:
|
|
61
|
+
context: The caller and their session.
|
|
75
62
|
store: The Store instance.
|
|
76
|
-
session_token: Session cookie value.
|
|
77
63
|
|
|
78
64
|
Returns:
|
|
79
65
|
The active Organisation.
|
|
80
66
|
|
|
81
67
|
Raises:
|
|
82
|
-
HTTPException: 400 if no organisation selected
|
|
68
|
+
HTTPException: 400 if no organisation is selected.
|
|
83
69
|
"""
|
|
84
|
-
|
|
85
|
-
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
86
|
-
|
|
87
|
-
result = store.auth.resolve_session(session_token)
|
|
88
|
-
if not result:
|
|
89
|
-
raise HTTPException(status_code=401, detail="Invalid or expired session")
|
|
90
|
-
|
|
91
|
-
_, session_row = result
|
|
70
|
+
_, session_row = context
|
|
92
71
|
if not session_row.organisation_id:
|
|
93
72
|
raise HTTPException(status_code=400, detail="No organisation selected")
|
|
94
|
-
|
|
95
73
|
return store.organisations.get(session_row.organisation_id)
|
|
96
74
|
|
|
97
75
|
|
|
@@ -112,4 +90,5 @@ def get_org_id(
|
|
|
112
90
|
# -- Dependency aliases --------------------------------------------------------
|
|
113
91
|
|
|
114
92
|
CurrentUserDep = Annotated[Profile, Depends(get_current_user)]
|
|
93
|
+
SessionContextDep = Annotated[tuple[Profile, AuthSession], Depends(get_session_context)]
|
|
115
94
|
OrgIdDep = Annotated[UUID, Depends(get_org_id)]
|
|
@@ -8,13 +8,11 @@ from uuid import UUID
|
|
|
8
8
|
|
|
9
9
|
from fastapi import Depends, HTTPException
|
|
10
10
|
from interloper.errors import NotFoundError
|
|
11
|
-
from interloper_db import Profile, Store
|
|
11
|
+
from interloper_db import Profile, Role, Store
|
|
12
12
|
|
|
13
13
|
from interloper_api.dependencies.auth import get_current_user, get_org_id
|
|
14
14
|
from interloper_api.dependencies.state import get_store
|
|
15
15
|
|
|
16
|
-
_ROLE_RANK = {"viewer": 0, "editor": 1, "admin": 2}
|
|
17
|
-
|
|
18
16
|
|
|
19
17
|
def authorize_org_member(
|
|
20
18
|
user: Profile,
|
|
@@ -43,13 +41,33 @@ def authorize_org_member(
|
|
|
43
41
|
Raises:
|
|
44
42
|
HTTPException: 404 if not a member, 403 if the role is insufficient.
|
|
45
43
|
"""
|
|
46
|
-
role = store.
|
|
44
|
+
role = store.members.role(org_id, user.id)
|
|
47
45
|
if role is None:
|
|
48
46
|
raise HTTPException(status_code=404, detail=detail)
|
|
49
|
-
if
|
|
47
|
+
if not Role.at_least(role, minimum):
|
|
50
48
|
raise HTTPException(status_code=403, detail=f"Requires {minimum} role or higher")
|
|
51
49
|
|
|
52
50
|
|
|
51
|
+
def authorize_organisation(user: Profile, org_id: UUID, store: Store, *, minimum: str = "viewer") -> None:
|
|
52
|
+
"""Authorize an organisation-addressed route: a member holding *minimum*, or a super-admin.
|
|
53
|
+
|
|
54
|
+
The organisation routes take the organisation from the path, so they serve
|
|
55
|
+
its own members and the platform's super-admins through one set of
|
|
56
|
+
endpoints. A super-admin passes on any live organisation; anyone else must
|
|
57
|
+
be a member, a non-member reading as a missing organisation.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
user: The authenticated user.
|
|
61
|
+
org_id: The organisation the path addresses.
|
|
62
|
+
store: The Store instance.
|
|
63
|
+
minimum: Minimum role a member needs (``viewer``, ``editor``, ``admin``).
|
|
64
|
+
"""
|
|
65
|
+
if user.is_super_admin:
|
|
66
|
+
store.organisations.get(org_id)
|
|
67
|
+
return
|
|
68
|
+
authorize_org_member(user, org_id, store, minimum=minimum, detail=f"Organisation {org_id} not found")
|
|
69
|
+
|
|
70
|
+
|
|
53
71
|
def load_authorized(
|
|
54
72
|
fetch: Callable[[UUID], Any],
|
|
55
73
|
entity_id: UUID,
|
|
@@ -109,10 +127,10 @@ def _check_role(
|
|
|
109
127
|
Raises:
|
|
110
128
|
HTTPException: 403 if insufficient permissions.
|
|
111
129
|
"""
|
|
112
|
-
role = store.
|
|
130
|
+
role = store.members.role(org_id, user.id)
|
|
113
131
|
if role is None:
|
|
114
132
|
raise HTTPException(status_code=403, detail="Not a member of this organisation")
|
|
115
|
-
if
|
|
133
|
+
if not Role.at_least(role, minimum):
|
|
116
134
|
raise HTTPException(status_code=403, detail=f"Requires {minimum} role or higher")
|
|
117
135
|
return user
|
|
118
136
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Outbound notifications the API sends on a user's behalf."""
|
|
2
|
+
|
|
3
|
+
from interloper_api.notifications.base import Email
|
|
4
|
+
from interloper_api.notifications.invitations import InvitationEmail
|
|
5
|
+
from interloper_api.notifications.super_admins import SuperAdminPromotionEmail
|
|
6
|
+
|
|
7
|
+
__all__ = ["Email", "InvitationEmail", "SuperAdminPromotionEmail"]
|