dagster-authentication 0.1.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.
- dagster_authentication/__init__.py +74 -0
- dagster_authentication/__main__.py +6 -0
- dagster_authentication/authorizer.py +115 -0
- dagster_authentication/config.py +115 -0
- dagster_authentication/entra.py +146 -0
- dagster_authentication/gate.py +281 -0
- dagster_authentication/identity.py +232 -0
- dagster_authentication/rbac.py +123 -0
- dagster_authentication/serve.py +265 -0
- dagster_authentication-0.1.0.dist-info/METADATA +300 -0
- dagster_authentication-0.1.0.dist-info/RECORD +14 -0
- dagster_authentication-0.1.0.dist-info/WHEEL +4 -0
- dagster_authentication-0.1.0.dist-info/entry_points.txt +2 -0
- dagster_authentication-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Microsoft Entra ID authentication and role-based authorisation for Dagster OSS.
|
|
2
|
+
|
|
3
|
+
Dagster OSS ships no authentication. This package adds Entra sign-in inside the
|
|
4
|
+
webserver process - no reverse proxy, no extra service - and enforces a role on
|
|
5
|
+
every request, including the GraphQL subscription WebSocket.
|
|
6
|
+
|
|
7
|
+
Usual use is the console script, a drop-in for ``dagster-webserver``::
|
|
8
|
+
|
|
9
|
+
dagster-authentication -w workspace.yaml -h 0.0.0.0 -p 443 \\
|
|
10
|
+
--ssl-certfile cert.pem --ssl-keyfile key.pem
|
|
11
|
+
|
|
12
|
+
To embed it in your own entrypoint instead, wrap the Dagster ASGI app
|
|
13
|
+
yourself::
|
|
14
|
+
|
|
15
|
+
from dagster_authentication import AuthConfig, build_app, build_authorizer
|
|
16
|
+
|
|
17
|
+
config = AuthConfig.from_env()
|
|
18
|
+
app = build_app(context, config, build_authorizer("identity"))
|
|
19
|
+
|
|
20
|
+
Authorisation is pluggable: implement the ``Authorizer`` protocol to gate on
|
|
21
|
+
whatever store you already have.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from dagster_authentication.authorizer import (
|
|
27
|
+
AllowListAuthorizer,
|
|
28
|
+
AuthorizationUnavailable,
|
|
29
|
+
Authorizer,
|
|
30
|
+
Decision,
|
|
31
|
+
)
|
|
32
|
+
from dagster_authentication.config import AuthConfig, ConfigError
|
|
33
|
+
from dagster_authentication.rbac import Role
|
|
34
|
+
|
|
35
|
+
# Importing these pulls in Dagster and Starlette, which is a second or two and
|
|
36
|
+
# a large dependency tree. Deferring them keeps `dagster_authentication.rbac` and the
|
|
37
|
+
# authorizers usable - and testable - on their own.
|
|
38
|
+
_LAZY = {
|
|
39
|
+
"AuthGate": "dagster_authentication.gate",
|
|
40
|
+
"build_app": "dagster_authentication.serve",
|
|
41
|
+
"build_authorizer": "dagster_authentication.serve",
|
|
42
|
+
"main": "dagster_authentication.serve",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
46
|
+
from dagster_authentication.gate import AuthGate
|
|
47
|
+
from dagster_authentication.serve import build_app, build_authorizer, main
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"AllowListAuthorizer",
|
|
51
|
+
"AuthConfig",
|
|
52
|
+
"AuthGate",
|
|
53
|
+
"AuthorizationUnavailable",
|
|
54
|
+
"Authorizer",
|
|
55
|
+
"ConfigError",
|
|
56
|
+
"Decision",
|
|
57
|
+
"Role",
|
|
58
|
+
"build_app",
|
|
59
|
+
"build_authorizer",
|
|
60
|
+
"main",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def __getattr__(name: str):
|
|
65
|
+
module_name = _LAZY.get(name)
|
|
66
|
+
if module_name is None:
|
|
67
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
68
|
+
from importlib import import_module
|
|
69
|
+
|
|
70
|
+
return getattr(import_module(module_name), name)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def __dir__() -> list[str]:
|
|
74
|
+
return sorted(__all__)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The authorisation boundary.
|
|
2
|
+
|
|
3
|
+
Entra proves *who* signed in. An ``Authorizer`` decides what they may do, so a
|
|
4
|
+
project can gate on whatever it already uses - a central identity app, a list of
|
|
5
|
+
addresses in the environment, or something of its own - without touching the
|
|
6
|
+
sign-in flow or the request gate.
|
|
7
|
+
|
|
8
|
+
Two rules every implementation must follow:
|
|
9
|
+
|
|
10
|
+
1. Return ``Decision.deny(...)`` when the user has no access.
|
|
11
|
+
2. Raise :class:`AuthorizationUnavailable` when the answer cannot be
|
|
12
|
+
determined. Callers turn that into a 503. Treating an unreachable
|
|
13
|
+
authorisation store as "no access" is the only safe reading - the
|
|
14
|
+
alternative is that an outage silently opens the server to everyone who can
|
|
15
|
+
authenticate.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
from dagster_authentication.config import DEFAULT_ENV_PREFIX, csv_env
|
|
26
|
+
from dagster_authentication.rbac import Role
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("dagster_authentication")
|
|
29
|
+
|
|
30
|
+
DEFAULT_DENIAL_MESSAGE = "You do not have access to this application."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthorizationUnavailable(RuntimeError):
|
|
34
|
+
"""The authorisation store could not be consulted, so access is refused."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Decision:
|
|
39
|
+
"""The outcome of an authorisation check.
|
|
40
|
+
|
|
41
|
+
``reason`` is for the service log and may name identifiers; ``message`` is
|
|
42
|
+
shown to the user and should stay free of internal detail.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
role: Role | None
|
|
46
|
+
reason: str = ""
|
|
47
|
+
message: str = ""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def allowed(self) -> bool:
|
|
51
|
+
return self.role is not None
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def allow(cls, role: Role, reason: str = "") -> Decision:
|
|
55
|
+
return cls(role=role, reason=reason)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def deny(cls, reason: str, message: str = DEFAULT_DENIAL_MESSAGE) -> Decision:
|
|
59
|
+
return cls(role=None, reason=reason, message=message)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@runtime_checkable
|
|
63
|
+
class Authorizer(Protocol):
|
|
64
|
+
"""Decides the role for an authenticated user."""
|
|
65
|
+
|
|
66
|
+
name: str
|
|
67
|
+
|
|
68
|
+
def decide(self, claims: Mapping[str, Any]) -> Decision:
|
|
69
|
+
"""Map ID-token claims onto a role, or deny."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AllowListAuthorizer:
|
|
74
|
+
"""Authorises against explicit lists of addresses or object ids.
|
|
75
|
+
|
|
76
|
+
For projects with no central access store. Entries are matched against the
|
|
77
|
+
Entra object id (``oid``) and the sign-in name (``preferred_username``),
|
|
78
|
+
case-insensitively, so either form works::
|
|
79
|
+
|
|
80
|
+
DAGSTER_AUTH_ADMINS=ada@example.com,3f2504e0-4f89-11d3-9a0c-0305e82c3301
|
|
81
|
+
DAGSTER_AUTH_VIEWERS=grace@example.com
|
|
82
|
+
|
|
83
|
+
Admin wins when an identifier appears in both lists.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
name = "allowlist"
|
|
87
|
+
|
|
88
|
+
def __init__(self, admins: frozenset[str], viewers: frozenset[str] = frozenset()) -> None:
|
|
89
|
+
self.admins = frozenset(entry.casefold() for entry in admins)
|
|
90
|
+
self.viewers = frozenset(entry.casefold() for entry in viewers)
|
|
91
|
+
if not self.admins and not self.viewers:
|
|
92
|
+
# Guarded here as well as at startup: an empty allow-list authorises
|
|
93
|
+
# nobody, which is never what the operator meant.
|
|
94
|
+
raise ValueError("an allow-list authorizer needs at least one admin or viewer")
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_env(cls, env_prefix: str = DEFAULT_ENV_PREFIX) -> AllowListAuthorizer:
|
|
98
|
+
return cls(csv_env(f"{env_prefix}_ADMINS"), csv_env(f"{env_prefix}_VIEWERS"))
|
|
99
|
+
|
|
100
|
+
def _identifiers(self, claims: Mapping[str, Any]) -> set[str]:
|
|
101
|
+
values = (claims.get("oid"), claims.get("preferred_username"), claims.get("email"))
|
|
102
|
+
return {str(value).casefold() for value in values if value}
|
|
103
|
+
|
|
104
|
+
def decide(self, claims: Mapping[str, Any]) -> Decision:
|
|
105
|
+
identifiers = self._identifiers(claims)
|
|
106
|
+
if not identifiers:
|
|
107
|
+
return Decision.deny(
|
|
108
|
+
"the ID token carried neither an oid nor a sign-in name",
|
|
109
|
+
"Sign-in succeeded, but the token did not identify your account.",
|
|
110
|
+
)
|
|
111
|
+
if identifiers & self.admins:
|
|
112
|
+
return Decision.allow(Role.ADMIN)
|
|
113
|
+
if identifiers & self.viewers:
|
|
114
|
+
return Decision.allow(Role.VIEWER)
|
|
115
|
+
return Decision.deny(f"none of {sorted(identifiers)} is on the allow-list")
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Environment-driven configuration for the authentication layer.
|
|
2
|
+
|
|
3
|
+
Everything is read and validated once at startup: a misconfigured webserver
|
|
4
|
+
should fail to boot loudly rather than come up serving unauthenticated traffic.
|
|
5
|
+
|
|
6
|
+
This module covers *authentication* only. Authorisation - who may use Dagster
|
|
7
|
+
and at what level - is decided by an :class:`~dagster_authentication.authorizer.Authorizer`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
DEFAULT_ENV_PREFIX = "DAGSTER_AUTH"
|
|
17
|
+
DEFAULT_SESSION_MAX_AGE = 8 * 3600
|
|
18
|
+
DEFAULT_SESSION_COOKIE = "dagster_authentication_session"
|
|
19
|
+
MIN_SESSION_SECRET_LEN = 32
|
|
20
|
+
|
|
21
|
+
# Hosts for which Entra permits a plain-http redirect URI.
|
|
22
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigError(RuntimeError):
|
|
26
|
+
"""Raised at startup when configuration is missing or unusable."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def required_env(name: str) -> str:
|
|
30
|
+
value = os.environ.get(name, "").strip()
|
|
31
|
+
if not value:
|
|
32
|
+
raise ConfigError(f"{name} is not set - refusing to start without it")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def positive_int_env(name: str, default: int) -> int:
|
|
37
|
+
raw = os.environ.get(name, "").strip()
|
|
38
|
+
if not raw:
|
|
39
|
+
return default
|
|
40
|
+
try:
|
|
41
|
+
value = int(raw)
|
|
42
|
+
except ValueError:
|
|
43
|
+
raise ConfigError(f"{name} must be an integer, got {raw!r}") from None
|
|
44
|
+
if value <= 0:
|
|
45
|
+
raise ConfigError(f"{name} must be positive, got {value}")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def csv_env(name: str) -> frozenset[str]:
|
|
50
|
+
"""A comma-separated environment variable as a set of trimmed entries."""
|
|
51
|
+
return frozenset(part.strip() for part in os.environ.get(name, "").split(",") if part.strip())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _redirect_uri_allowed(uri: str) -> bool:
|
|
55
|
+
"""Entra accepts https anywhere, and http only on the loopback host.
|
|
56
|
+
|
|
57
|
+
Mirroring that rule here means --allow-insecure-http is actually usable for
|
|
58
|
+
local development instead of failing validation before startup.
|
|
59
|
+
"""
|
|
60
|
+
if uri.startswith("https://"):
|
|
61
|
+
return True
|
|
62
|
+
if not uri.startswith("http://"):
|
|
63
|
+
return False
|
|
64
|
+
return urlsplit(uri).hostname in LOOPBACK_HOSTS
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class AuthConfig:
|
|
69
|
+
tenant_id: str
|
|
70
|
+
client_id: str
|
|
71
|
+
client_secret: str
|
|
72
|
+
redirect_uri: str
|
|
73
|
+
session_secret: str
|
|
74
|
+
session_max_age: int
|
|
75
|
+
session_cookie: str
|
|
76
|
+
path_prefix: str
|
|
77
|
+
env_prefix: str = DEFAULT_ENV_PREFIX
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def authority(self) -> str:
|
|
81
|
+
return f"https://login.microsoftonline.com/{self.tenant_id}"
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_env(cls, path_prefix: str = "", env_prefix: str = DEFAULT_ENV_PREFIX) -> AuthConfig:
|
|
85
|
+
"""Read and validate the authentication settings.
|
|
86
|
+
|
|
87
|
+
``env_prefix`` lets a project namespace its variables; the default
|
|
88
|
+
expects ``DAGSTER_AUTH_TENANT_ID`` and friends.
|
|
89
|
+
"""
|
|
90
|
+
def var(suffix: str) -> str:
|
|
91
|
+
return f"{env_prefix}_{suffix}"
|
|
92
|
+
|
|
93
|
+
config = cls(
|
|
94
|
+
tenant_id=required_env(var("TENANT_ID")),
|
|
95
|
+
client_id=required_env(var("CLIENT_ID")),
|
|
96
|
+
client_secret=required_env(var("CLIENT_SECRET")),
|
|
97
|
+
redirect_uri=required_env(var("REDIRECT_URI")),
|
|
98
|
+
session_secret=required_env(var("SESSION_SECRET")),
|
|
99
|
+
session_max_age=positive_int_env(var("SESSION_MAX_AGE"), DEFAULT_SESSION_MAX_AGE),
|
|
100
|
+
session_cookie=os.environ.get(var("SESSION_COOKIE"), "").strip()
|
|
101
|
+
or DEFAULT_SESSION_COOKIE,
|
|
102
|
+
path_prefix=path_prefix.rstrip("/"),
|
|
103
|
+
env_prefix=env_prefix,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if len(config.session_secret) < MIN_SESSION_SECRET_LEN:
|
|
107
|
+
raise ConfigError(
|
|
108
|
+
f"{var('SESSION_SECRET')} must be at least {MIN_SESSION_SECRET_LEN} characters"
|
|
109
|
+
)
|
|
110
|
+
if not _redirect_uri_allowed(config.redirect_uri):
|
|
111
|
+
raise ConfigError(
|
|
112
|
+
f"{var('REDIRECT_URI')} must be https, or http on localhost - those are the "
|
|
113
|
+
f"only redirect URIs Entra accepts. Got {config.redirect_uri!r}"
|
|
114
|
+
)
|
|
115
|
+
return config
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Entra ID (Azure AD) OpenID Connect sign-in, via MSAL.
|
|
2
|
+
|
|
3
|
+
MSAL owns the parts of the authorisation-code flow that are easy to get subtly
|
|
4
|
+
wrong - the state parameter, the nonce, and the PKCE verifier - so this module
|
|
5
|
+
only persists the flow between the two requests and maps claims onto a session.
|
|
6
|
+
|
|
7
|
+
Entra proves *who* the user is. What they may do is decided by the configured
|
|
8
|
+
:class:`~dagster_authentication.authorizer.Authorizer`.
|
|
9
|
+
|
|
10
|
+
The route handlers are deliberately synchronous. MSAL performs blocking network
|
|
11
|
+
calls (OIDC discovery, the token exchange) and an authorizer may hit a
|
|
12
|
+
database; Starlette runs non-async endpoints in a threadpool, so declaring them
|
|
13
|
+
``async def`` would stall the event loop for every other request during a
|
|
14
|
+
sign-in.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
import msal
|
|
22
|
+
from starlette.requests import Request
|
|
23
|
+
from starlette.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
|
|
24
|
+
from starlette.routing import Route, Router
|
|
25
|
+
|
|
26
|
+
from dagster_authentication.authorizer import AuthorizationUnavailable, Authorizer
|
|
27
|
+
from dagster_authentication.config import AuthConfig
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("dagster_authentication")
|
|
30
|
+
|
|
31
|
+
# No groups claim is needed on the app registration: authorisation is the
|
|
32
|
+
# authorizer's job, not a token claim. MSAL adds the reserved
|
|
33
|
+
# openid/profile/offline_access scopes itself.
|
|
34
|
+
SCOPES: list[str] = []
|
|
35
|
+
|
|
36
|
+
_SIGNED_OUT_PAGE = """<!doctype html>
|
|
37
|
+
<title>Signed out</title>
|
|
38
|
+
<style>
|
|
39
|
+
body {{ font: 15px/1.6 system-ui, sans-serif; margin: 15vh auto; max-width: 28rem;
|
|
40
|
+
padding: 0 1.5rem; color: #1a1a1a; }}
|
|
41
|
+
a {{ color: #0b6bcb; }}
|
|
42
|
+
</style>
|
|
43
|
+
<h1>Signed out</h1>
|
|
44
|
+
<p>Your session has ended.</p>
|
|
45
|
+
<p><a href="{login}">Sign in again</a></p>
|
|
46
|
+
<p style="color:#666;font-size:.9em">You are still signed in to Microsoft. Sign
|
|
47
|
+
out of your Microsoft account too if you are on a shared machine.</p>
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_auth_router(config: AuthConfig, authorizer: Authorizer) -> Router:
|
|
52
|
+
"""The /auth routes: the only unauthenticated surface of the server."""
|
|
53
|
+
# Built once. Constructing a ConfidentialClientApplication performs OIDC
|
|
54
|
+
# discovery, so doing it per request would add a round trip to every login.
|
|
55
|
+
client = msal.ConfidentialClientApplication(
|
|
56
|
+
config.client_id,
|
|
57
|
+
authority=config.authority,
|
|
58
|
+
client_credential=config.client_secret,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
prefix = config.path_prefix
|
|
62
|
+
login_path = f"{prefix}/auth/login"
|
|
63
|
+
home_path = prefix or "/"
|
|
64
|
+
|
|
65
|
+
def login(request: Request) -> Response:
|
|
66
|
+
flow = client.initiate_auth_code_flow(SCOPES, redirect_uri=config.redirect_uri)
|
|
67
|
+
request.session.clear()
|
|
68
|
+
request.session["flow"] = flow
|
|
69
|
+
return RedirectResponse(url=flow["auth_uri"], status_code=302)
|
|
70
|
+
|
|
71
|
+
def callback(request: Request) -> Response:
|
|
72
|
+
flow = request.session.pop("flow", None)
|
|
73
|
+
if not isinstance(flow, dict):
|
|
74
|
+
# No flow in the session: a bookmarked callback, a replay, or the
|
|
75
|
+
# cookie expired mid-sign-in. Start over rather than erroring.
|
|
76
|
+
return RedirectResponse(url=login_path, status_code=302)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
result = client.acquire_token_by_auth_code_flow(flow, dict(request.query_params))
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
# MSAL raises this on a state mismatch or a replayed callback.
|
|
82
|
+
logger.warning("rejected auth callback: %s", exc)
|
|
83
|
+
return PlainTextResponse("Sign-in could not be verified. Please try again.", 400)
|
|
84
|
+
|
|
85
|
+
if "error" in result:
|
|
86
|
+
detail = result.get("error_description") or result["error"]
|
|
87
|
+
logger.warning("Entra returned an error: %s", detail)
|
|
88
|
+
return PlainTextResponse(f"Sign-in failed: {detail}", 403)
|
|
89
|
+
|
|
90
|
+
claims = result.get("id_token_claims") or {}
|
|
91
|
+
user = claims.get("preferred_username")
|
|
92
|
+
|
|
93
|
+
if claims.get("tid") != config.tenant_id:
|
|
94
|
+
logger.warning("rejected token from tenant %s", claims.get("tid"))
|
|
95
|
+
return PlainTextResponse("This account is from an unrecognised tenant.", 403)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
decision = authorizer.decide(claims)
|
|
99
|
+
except AuthorizationUnavailable as exc:
|
|
100
|
+
# Fail closed: an unreachable authorisation store must not mean
|
|
101
|
+
# "allow everyone who can authenticate".
|
|
102
|
+
logger.error("denied %s: %s", user, exc)
|
|
103
|
+
return PlainTextResponse(
|
|
104
|
+
"Your access could not be verified because the authorisation service is "
|
|
105
|
+
"unavailable. Please try again shortly.",
|
|
106
|
+
503,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if not decision.allowed:
|
|
110
|
+
logger.warning("denied %s via %s: %s", user, authorizer.name, decision.reason)
|
|
111
|
+
return PlainTextResponse(decision.message, 403)
|
|
112
|
+
|
|
113
|
+
assert decision.role is not None # narrowed by decision.allowed
|
|
114
|
+
request.session.clear()
|
|
115
|
+
request.session.update(
|
|
116
|
+
{
|
|
117
|
+
"role": int(decision.role),
|
|
118
|
+
"sub": claims.get("sub"),
|
|
119
|
+
"oid": claims.get("oid"),
|
|
120
|
+
"email": user,
|
|
121
|
+
"name": claims.get("name"),
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
logger.info("signed in %s as %s", user, decision.role.name)
|
|
125
|
+
return RedirectResponse(url=home_path, status_code=302)
|
|
126
|
+
|
|
127
|
+
def logout(request: Request) -> Response:
|
|
128
|
+
user = request.session.get("email")
|
|
129
|
+
request.session.clear()
|
|
130
|
+
if user:
|
|
131
|
+
logger.info("signed out %s", user)
|
|
132
|
+
return HTMLResponse(_SIGNED_OUT_PAGE.format(login=login_path))
|
|
133
|
+
|
|
134
|
+
def health(_: Request) -> Response:
|
|
135
|
+
# Public by design so an uptime monitor can poll without a session.
|
|
136
|
+
# Returns nothing about versions or workspace contents.
|
|
137
|
+
return PlainTextResponse("ok")
|
|
138
|
+
|
|
139
|
+
return Router(
|
|
140
|
+
routes=[
|
|
141
|
+
Route(f"{prefix}/auth/login", login, methods=["GET"]),
|
|
142
|
+
Route(f"{prefix}/auth/callback", callback, methods=["GET"]),
|
|
143
|
+
Route(f"{prefix}/auth/logout", logout, methods=["GET"]),
|
|
144
|
+
Route(f"{prefix}/auth/health", health, methods=["GET"]),
|
|
145
|
+
]
|
|
146
|
+
)
|