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.
@@ -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,6 @@
1
+ """``python -m dagster_authentication`` - equivalent to the ``dagster-authentication`` script."""
2
+
3
+ from dagster_authentication.serve import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ )