pyfastauth 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.
- fastauth/__init__.py +29 -0
- fastauth/authorization/__init__.py +31 -0
- fastauth/authorization/roles.py +210 -0
- fastauth/authorization/scopes.py +165 -0
- fastauth/cli/__init__.py +30 -0
- fastauth/cli/_context.py +391 -0
- fastauth/cli/app.py +67 -0
- fastauth/cli/config_cmd.py +120 -0
- fastauth/cli/database.py +120 -0
- fastauth/cli/doctor.py +692 -0
- fastauth/cli/init.py +825 -0
- fastauth/cli/routes.py +98 -0
- fastauth/cli/secret.py +70 -0
- fastauth/cli/sessions.py +192 -0
- fastauth/cli/users.py +319 -0
- fastauth/clock.py +26 -0
- fastauth/config/__init__.py +84 -0
- fastauth/config/defaults.py +214 -0
- fastauth/config/duration.py +126 -0
- fastauth/config/environment.py +171 -0
- fastauth/config/loader.py +167 -0
- fastauth/config/models.py +717 -0
- fastauth/config/ratelimit.py +96 -0
- fastauth/config/validation.py +497 -0
- fastauth/container.py +589 -0
- fastauth/core.py +258 -0
- fastauth/db/__init__.py +44 -0
- fastauth/db/alembic.ini +90 -0
- fastauth/db/base.py +92 -0
- fastauth/db/engine.py +173 -0
- fastauth/db/mappers.py +74 -0
- fastauth/db/migration_runner.py +281 -0
- fastauth/db/migrations/__init__.py +184 -0
- fastauth/db/migrations/env.py +217 -0
- fastauth/db/migrations/script.py.mako +53 -0
- fastauth/db/migrations/versions/0001_initial_schema.py +158 -0
- fastauth/db/models.py +223 -0
- fastauth/db/stores/__init__.py +13 -0
- fastauth/db/stores/session_store.py +279 -0
- fastauth/db/stores/user_store.py +219 -0
- fastauth/db/types.py +101 -0
- fastauth/db/uow.py +118 -0
- fastauth/dependencies.py +159 -0
- fastauth/events.py +68 -0
- fastauth/exceptions.py +271 -0
- fastauth/installer.py +440 -0
- fastauth/mongo/__init__.py +194 -0
- fastauth/mongo/codec.py +104 -0
- fastauth/mongo/database.py +98 -0
- fastauth/mongo/documents.py +160 -0
- fastauth/mongo/indexes.py +111 -0
- fastauth/mongo/stores/__init__.py +13 -0
- fastauth/mongo/stores/session_store.py +291 -0
- fastauth/mongo/stores/user_store.py +310 -0
- fastauth/ownership/__init__.py +45 -0
- fastauth/ownership/_predicates.py +144 -0
- fastauth/ownership/admin.py +163 -0
- fastauth/ownership/mixins.py +120 -0
- fastauth/ownership/repository.py +429 -0
- fastauth/protocols/__init__.py +42 -0
- fastauth/protocols/clock.py +22 -0
- fastauth/protocols/events.py +28 -0
- fastauth/protocols/passwords.py +35 -0
- fastauth/protocols/rate_limit.py +25 -0
- fastauth/protocols/sessions.py +76 -0
- fastauth/protocols/templates.py +25 -0
- fastauth/protocols/tokens.py +66 -0
- fastauth/protocols/transport.py +49 -0
- fastauth/protocols/uow.py +88 -0
- fastauth/protocols/users.py +75 -0
- fastauth/py.typed +0 -0
- fastauth/routes/__init__.py +1 -0
- fastauth/routes/api.py +592 -0
- fastauth/routes/errors.py +239 -0
- fastauth/routes/schemas.py +256 -0
- fastauth/routes/table.py +59 -0
- fastauth/routes/ui.py +771 -0
- fastauth/security/__init__.py +51 -0
- fastauth/security/cookies.py +133 -0
- fastauth/security/csrf.py +135 -0
- fastauth/security/emails.py +106 -0
- fastauth/security/headers.py +144 -0
- fastauth/security/passwords.py +183 -0
- fastauth/security/rate_limit.py +127 -0
- fastauth/security/redirects.py +157 -0
- fastauth/security/secrets.py +314 -0
- fastauth/security/tokens.py +367 -0
- fastauth/services/__init__.py +47 -0
- fastauth/services/access.py +222 -0
- fastauth/services/credentials.py +125 -0
- fastauth/services/event_bus.py +125 -0
- fastauth/services/flows/__init__.py +23 -0
- fastauth/services/flows/login.py +238 -0
- fastauth/services/flows/logout.py +120 -0
- fastauth/services/flows/refresh.py +214 -0
- fastauth/services/flows/signup.py +216 -0
- fastauth/services/identity.py +134 -0
- fastauth/services/sessions.py +305 -0
- fastauth/services/tokens.py +274 -0
- fastauth/testing/__init__.py +69 -0
- fastauth/testing/clock.py +70 -0
- fastauth/testing/sessions.py +498 -0
- fastauth/testing/users.py +384 -0
- fastauth/transport/__init__.py +15 -0
- fastauth/transport/extractors.py +117 -0
- fastauth/types.py +284 -0
- fastauth/ui/__init__.py +15 -0
- fastauth/ui/renderer.py +265 -0
- fastauth/ui/static/fastauth.css +409 -0
- fastauth/ui/templates/account.html +119 -0
- fastauth/ui/templates/base.html +44 -0
- fastauth/ui/templates/error.html +37 -0
- fastauth/ui/templates/login.html +63 -0
- fastauth/ui/templates/signup.html +107 -0
- pyfastauth-0.1.0.dist-info/METADATA +359 -0
- pyfastauth-0.1.0.dist-info/RECORD +119 -0
- pyfastauth-0.1.0.dist-info/WHEEL +4 -0
- pyfastauth-0.1.0.dist-info/entry_points.txt +2 -0
- pyfastauth-0.1.0.dist-info/licenses/LICENSE +21 -0
fastauth/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""fastauth -- authentication and authorization for FastAPI.
|
|
2
|
+
|
|
3
|
+
Add working authentication to an existing application in three lines::
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastauth import FastAuth
|
|
7
|
+
|
|
8
|
+
app = FastAPI()
|
|
9
|
+
auth = FastAuth(config="auth.yaml")
|
|
10
|
+
auth.install(app)
|
|
11
|
+
|
|
12
|
+
The public surface is deliberately small: the four names below, plus
|
|
13
|
+
``fastauth.ownership`` for per-user data isolation. Everything else is an
|
|
14
|
+
implementation detail and may change without a major version bump.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from fastauth.config.models import AuthConfig
|
|
18
|
+
from fastauth.core import FastAuth
|
|
19
|
+
from fastauth.types import AuthContext, AuthUser
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AuthConfig",
|
|
25
|
+
"AuthContext",
|
|
26
|
+
"AuthUser",
|
|
27
|
+
"FastAuth",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Authorization: scope matching and role resolution (spec section 15).
|
|
2
|
+
|
|
3
|
+
Everything in this package is pure. It takes declared roles and held scopes and
|
|
4
|
+
answers questions about them; it never reads configuration files, touches the
|
|
5
|
+
database, or logs. That is what makes the matching rules exhaustively testable,
|
|
6
|
+
which spec 15.2 explicitly requires.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from fastauth.authorization.roles import RoleRegistry
|
|
12
|
+
from fastauth.authorization.scopes import (
|
|
13
|
+
GLOBAL_WILDCARD,
|
|
14
|
+
SCOPE_PATTERN,
|
|
15
|
+
has_scope,
|
|
16
|
+
is_valid_scope,
|
|
17
|
+
matches,
|
|
18
|
+
missing_scopes,
|
|
19
|
+
scope_matches,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"GLOBAL_WILDCARD",
|
|
24
|
+
"SCOPE_PATTERN",
|
|
25
|
+
"RoleRegistry",
|
|
26
|
+
"has_scope",
|
|
27
|
+
"is_valid_scope",
|
|
28
|
+
"matches",
|
|
29
|
+
"missing_scopes",
|
|
30
|
+
"scope_matches",
|
|
31
|
+
]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Role inheritance resolution (spec section 15.3).
|
|
2
|
+
|
|
3
|
+
Roles are declared in ``auth.yaml``; user-role assignments live in the database
|
|
4
|
+
(spec 15.1). This module owns the step between the two: turning declared roles
|
|
5
|
+
into the flat scope sets that authorization checks actually use.
|
|
6
|
+
|
|
7
|
+
The whole resolution happens once, at configuration load time, and produces an
|
|
8
|
+
immutable :class:`RoleRegistry`. Nothing resolves inheritance per request --
|
|
9
|
+
that would make the cost of a deep hierarchy a per-request cost, and would move
|
|
10
|
+
cycle detection into the request path where it can only be reported as a 500.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Iterable, Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from fastauth.exceptions import RoleCycleError, UnknownRoleError
|
|
19
|
+
from fastauth.types import RoleDefinition, RoleName, Scope
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class RoleRegistry:
|
|
24
|
+
"""The resolved role graph: declared roles plus their inherited scopes.
|
|
25
|
+
|
|
26
|
+
Immutable and self-contained, so it can be built once at startup and shared
|
|
27
|
+
by every request without locking. Construct it with
|
|
28
|
+
:meth:`from_definitions`; the constructor takes already-resolved data and
|
|
29
|
+
performs no validation of its own.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
definitions: Mapping[RoleName, RoleDefinition]
|
|
33
|
+
"""The declarations exactly as they appeared in ``auth.yaml``."""
|
|
34
|
+
|
|
35
|
+
resolved_scopes: Mapping[RoleName, frozenset[Scope]]
|
|
36
|
+
"""Per role: its own scopes unioned with every ancestor's, deduplicated."""
|
|
37
|
+
|
|
38
|
+
ancestry: Mapping[RoleName, tuple[RoleName, ...]]
|
|
39
|
+
"""Per role: itself plus every role it inherits from, transitively, sorted."""
|
|
40
|
+
|
|
41
|
+
inheritance_enabled: bool = True
|
|
42
|
+
"""Mirrors ``authorization.role_inheritance_enabled``. When ``False`` the
|
|
43
|
+
``inherits`` lists were validated but deliberately not applied."""
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_definitions(
|
|
47
|
+
cls,
|
|
48
|
+
definitions: Mapping[RoleName, RoleDefinition],
|
|
49
|
+
*,
|
|
50
|
+
inheritance_enabled: bool = True,
|
|
51
|
+
) -> RoleRegistry:
|
|
52
|
+
"""Resolve inheritance and build a registry.
|
|
53
|
+
|
|
54
|
+
Parents are validated even when ``inheritance_enabled`` is ``False``: a
|
|
55
|
+
dangling parent or a cycle is a mistake in the file regardless of
|
|
56
|
+
whether the feature that reads it is switched on, and it would become a
|
|
57
|
+
surprise failure the day someone enables inheritance.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
definitions: Declared roles, keyed by name.
|
|
61
|
+
inheritance_enabled: When ``False``, each role resolves to exactly
|
|
62
|
+
its own scopes.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
An immutable registry.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
UnknownRoleError: A role inherits from a name that is not declared.
|
|
69
|
+
RoleCycleError: Inheritance contains a cycle; the error carries the
|
|
70
|
+
actual cycle path, e.g. ``a -> b -> a``.
|
|
71
|
+
"""
|
|
72
|
+
_reject_unknown_parents(definitions)
|
|
73
|
+
order = _topological_order(definitions)
|
|
74
|
+
|
|
75
|
+
resolved: dict[RoleName, frozenset[Scope]] = {}
|
|
76
|
+
ancestry: dict[RoleName, tuple[RoleName, ...]] = {}
|
|
77
|
+
for name in order:
|
|
78
|
+
definition = definitions[name]
|
|
79
|
+
scopes = set(definition.scopes)
|
|
80
|
+
lineage = {name}
|
|
81
|
+
if inheritance_enabled:
|
|
82
|
+
for parent in definition.inherits:
|
|
83
|
+
# Parents precede children in `order`, so their resolution
|
|
84
|
+
# is already final -- one pass, no fixed-point iteration.
|
|
85
|
+
scopes |= resolved[parent]
|
|
86
|
+
lineage |= set(ancestry[parent])
|
|
87
|
+
resolved[name] = frozenset(scopes)
|
|
88
|
+
ancestry[name] = tuple(sorted(lineage))
|
|
89
|
+
|
|
90
|
+
return cls(
|
|
91
|
+
definitions=dict(definitions),
|
|
92
|
+
resolved_scopes=resolved,
|
|
93
|
+
ancestry=ancestry,
|
|
94
|
+
inheritance_enabled=inheritance_enabled,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def known(self, role: str) -> bool:
|
|
98
|
+
"""Report whether ``role`` is declared in the configuration.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
role: A role name, typically one read from the database.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
``True`` if the role exists in ``auth.yaml``.
|
|
105
|
+
"""
|
|
106
|
+
return RoleName(role) in self.definitions
|
|
107
|
+
|
|
108
|
+
def scopes_for(self, roles: Iterable[str]) -> frozenset[Scope]:
|
|
109
|
+
"""Union the resolved scopes of every named role.
|
|
110
|
+
|
|
111
|
+
Roles that are not declared are ignored rather than raising. A role row
|
|
112
|
+
can outlive its YAML declaration -- someone deletes ``reviewer`` from
|
|
113
|
+
the file while users still carry it -- and the safe reading of an
|
|
114
|
+
undeclared role is "grants nothing", not "crash every request that
|
|
115
|
+
user makes".
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
roles: Role names, typically from the user's database rows.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Every scope those roles grant, deduplicated.
|
|
122
|
+
"""
|
|
123
|
+
scopes: set[Scope] = set()
|
|
124
|
+
for role in roles:
|
|
125
|
+
scopes |= self.resolved_scopes.get(RoleName(role), frozenset())
|
|
126
|
+
return frozenset(scopes)
|
|
127
|
+
|
|
128
|
+
def effective_roles(self, roles: Iterable[str]) -> tuple[RoleName, ...]:
|
|
129
|
+
"""Expand role names to include everything they inherit.
|
|
130
|
+
|
|
131
|
+
Sorted and deduplicated so that the ``roles`` claim of an access token
|
|
132
|
+
is stable: two tokens issued for the same user must not differ only in
|
|
133
|
+
list order, or byte-comparing them becomes meaningless in tests and
|
|
134
|
+
logs. Undeclared roles are dropped, for the reason given on
|
|
135
|
+
:meth:`scopes_for`.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
roles: Directly assigned role names.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The assigned roles plus their ancestors, sorted.
|
|
142
|
+
"""
|
|
143
|
+
effective: set[RoleName] = set()
|
|
144
|
+
for role in roles:
|
|
145
|
+
effective |= set(self.ancestry.get(RoleName(role), ()))
|
|
146
|
+
return tuple(sorted(effective))
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def role_names(self) -> tuple[RoleName, ...]:
|
|
150
|
+
"""Every declared role name, sorted."""
|
|
151
|
+
return tuple(sorted(self.definitions))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _reject_unknown_parents(definitions: Mapping[RoleName, RoleDefinition]) -> None:
|
|
155
|
+
"""Fail startup when a role inherits from something undeclared (spec 15.3)."""
|
|
156
|
+
for name, definition in definitions.items():
|
|
157
|
+
for parent in definition.inherits:
|
|
158
|
+
if parent not in definitions:
|
|
159
|
+
raise UnknownRoleError(
|
|
160
|
+
f"role {name!r} inherits from undeclared role {parent!r}",
|
|
161
|
+
yaml_path=f"roles.{name}.inherits",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _topological_order(
|
|
166
|
+
definitions: Mapping[RoleName, RoleDefinition],
|
|
167
|
+
) -> tuple[RoleName, ...]:
|
|
168
|
+
"""Order roles so every parent precedes its children.
|
|
169
|
+
|
|
170
|
+
Iterative depth-first search rather than recursion: the path stack is what
|
|
171
|
+
lets the cycle error name the actual cycle instead of merely reporting that
|
|
172
|
+
one exists.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
RoleCycleError: with the offending chain, e.g. ``["a", "b", "a"]``.
|
|
176
|
+
"""
|
|
177
|
+
order: list[RoleName] = []
|
|
178
|
+
finished: set[RoleName] = set()
|
|
179
|
+
path: list[RoleName] = []
|
|
180
|
+
on_path: set[RoleName] = set()
|
|
181
|
+
|
|
182
|
+
# Roots are visited in sorted order so that the resulting order -- and
|
|
183
|
+
# therefore any error message derived from it -- is reproducible.
|
|
184
|
+
for root in sorted(definitions):
|
|
185
|
+
if root in finished:
|
|
186
|
+
continue
|
|
187
|
+
# Each frame is (role, index of the next parent to descend into).
|
|
188
|
+
stack: list[tuple[RoleName, int]] = [(root, 0)]
|
|
189
|
+
while stack:
|
|
190
|
+
name, cursor = stack[-1]
|
|
191
|
+
if cursor == 0:
|
|
192
|
+
if name in finished:
|
|
193
|
+
stack.pop()
|
|
194
|
+
continue
|
|
195
|
+
if name in on_path:
|
|
196
|
+
raise RoleCycleError([*path[path.index(name) :], name])
|
|
197
|
+
path.append(name)
|
|
198
|
+
on_path.add(name)
|
|
199
|
+
parents = definitions[name].inherits
|
|
200
|
+
if cursor < len(parents):
|
|
201
|
+
stack[-1] = (name, cursor + 1)
|
|
202
|
+
stack.append((parents[cursor], 0))
|
|
203
|
+
continue
|
|
204
|
+
order.append(name)
|
|
205
|
+
finished.add(name)
|
|
206
|
+
on_path.discard(name)
|
|
207
|
+
path.pop()
|
|
208
|
+
stack.pop()
|
|
209
|
+
|
|
210
|
+
return tuple(order)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Scope matching (spec sections 15.2 and 6.6).
|
|
2
|
+
|
|
3
|
+
The spec names three shapes -- exact (``contracts:read``), resource wildcard
|
|
4
|
+
(``contracts:*``) and global (``*``) -- and requires matching to be
|
|
5
|
+
"deterministic and unit tested". It does not define the algorithm, so this
|
|
6
|
+
module does, once, for the whole package:
|
|
7
|
+
|
|
8
|
+
1. A scope is a ``:``-separated sequence of segments.
|
|
9
|
+
2. Granted and required segments are compared pairwise, left to right.
|
|
10
|
+
3. A granted ``*`` in a non-final position matches exactly one segment
|
|
11
|
+
(``*:read`` grants read on any resource).
|
|
12
|
+
4. A granted ``*`` in the *final* position matches one or more remaining
|
|
13
|
+
segments, so ``contracts:*`` grants ``contracts:read`` and also
|
|
14
|
+
``contracts:findings:read``.
|
|
15
|
+
5. The bare global ``*`` grants everything.
|
|
16
|
+
6. A *required* wildcard demands wildcard-or-broader authority: ``admin:*`` is
|
|
17
|
+
satisfied by ``*`` or ``admin:*``, but never by ``admin:read``. Requiring
|
|
18
|
+
``admin:*`` (spec 6.6) means "full authority over admin", and a holder of
|
|
19
|
+
one narrow admin scope does not have that.
|
|
20
|
+
|
|
21
|
+
Consequence of rules 3 and 4: ``contracts:*`` does not match the bare scope
|
|
22
|
+
``contracts``. The wildcard stands for segments that exist; a route requiring
|
|
23
|
+
``contracts`` is asking for a different, coarser permission.
|
|
24
|
+
|
|
25
|
+
Every function here is pure: no configuration, no I/O, no logging. Mid-string
|
|
26
|
+
wildcards such as ``con*racts`` are rejected at configuration load time by
|
|
27
|
+
:func:`is_valid_scope`, so matching never has to consider them.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from collections.abc import Iterable
|
|
34
|
+
from typing import Final
|
|
35
|
+
|
|
36
|
+
from fastauth.types import Scope, ScopeMatch
|
|
37
|
+
|
|
38
|
+
SEGMENT_SEPARATOR: Final = ":"
|
|
39
|
+
WILDCARD: Final = "*"
|
|
40
|
+
GLOBAL_WILDCARD: Final[Scope] = Scope("*")
|
|
41
|
+
|
|
42
|
+
SCOPE_PATTERN: Final[re.Pattern[str]] = re.compile(r"\A[A-Za-z0-9_:*-]+\Z")
|
|
43
|
+
"""Approved characters for a scope string (spec section 7.2)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_valid_scope(scope: str) -> bool:
|
|
47
|
+
"""Report whether ``scope`` is well formed.
|
|
48
|
+
|
|
49
|
+
Two rules beyond the character set: no empty segments (``a::b`` and a
|
|
50
|
+
leading or trailing ``:``), and a ``*`` may only appear as a whole segment.
|
|
51
|
+
``con*racts`` is rejected here rather than being given a meaning, because a
|
|
52
|
+
partial-match wildcard in an authorization grant is far more likely to be a
|
|
53
|
+
typo than an intention.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
scope: The candidate scope string, exactly as written in ``auth.yaml``.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
``True`` if the scope is usable by :func:`scope_matches`.
|
|
60
|
+
"""
|
|
61
|
+
if not scope or not SCOPE_PATTERN.match(scope):
|
|
62
|
+
return False
|
|
63
|
+
return all(
|
|
64
|
+
segment and (WILDCARD not in segment or segment == WILDCARD)
|
|
65
|
+
for segment in scope.split(SEGMENT_SEPARATOR)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def scope_matches(granted: str, required: str) -> bool:
|
|
70
|
+
"""Report whether one granted scope satisfies one required scope.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
granted: A scope the user holds, e.g. ``"contracts:*"``.
|
|
74
|
+
required: A scope the route demands, e.g. ``"contracts:read"``.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
``True`` when ``granted`` covers ``required`` under the rules in the
|
|
78
|
+
module docstring.
|
|
79
|
+
"""
|
|
80
|
+
if granted == GLOBAL_WILDCARD:
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
granted_segments = granted.split(SEGMENT_SEPARATOR)
|
|
84
|
+
required_segments = required.split(SEGMENT_SEPARATOR)
|
|
85
|
+
last = len(granted_segments) - 1
|
|
86
|
+
|
|
87
|
+
for position, segment in enumerate(granted_segments):
|
|
88
|
+
if position >= len(required_segments):
|
|
89
|
+
# The grant is more specific than the requirement, e.g. granted
|
|
90
|
+
# "contracts:read:own" against required "contracts:read".
|
|
91
|
+
return False
|
|
92
|
+
if segment == WILDCARD:
|
|
93
|
+
if position == last:
|
|
94
|
+
return True # trailing wildcard absorbs every remaining segment
|
|
95
|
+
continue # segment wildcard consumes exactly one segment
|
|
96
|
+
if segment != required_segments[position]:
|
|
97
|
+
# Also the branch that rejects a literal grant against a required
|
|
98
|
+
# wildcard: "read" != "*".
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
return len(granted_segments) == len(required_segments)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def has_scope(granted: Iterable[str], required: str) -> bool:
|
|
105
|
+
"""Report whether any held scope satisfies ``required``.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
granted: Every scope the user holds, in any order.
|
|
109
|
+
required: The scope the route demands.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
``True`` if at least one granted scope matches.
|
|
113
|
+
"""
|
|
114
|
+
return any(scope_matches(held, required) for held in granted)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def missing_scopes(
|
|
118
|
+
granted: Iterable[str],
|
|
119
|
+
required: Iterable[str],
|
|
120
|
+
) -> tuple[Scope, ...]:
|
|
121
|
+
"""List the required scopes that ``granted`` does not satisfy.
|
|
122
|
+
|
|
123
|
+
Returned in the order the route declared them, so the structured
|
|
124
|
+
authorization-denied log (spec 15.5) reads the same way the route does. The
|
|
125
|
+
public 403 body stays generic; only internal logs receive this detail.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
granted: Every scope the user holds.
|
|
129
|
+
required: The scopes the route demands.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The unsatisfied scopes, possibly empty.
|
|
133
|
+
"""
|
|
134
|
+
held = tuple(granted)
|
|
135
|
+
return tuple(Scope(scope) for scope in required if not has_scope(held, scope))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def matches(
|
|
139
|
+
granted: Iterable[str],
|
|
140
|
+
required: Iterable[str],
|
|
141
|
+
match: ScopeMatch = "all",
|
|
142
|
+
) -> bool:
|
|
143
|
+
"""Evaluate a route's scope requirement against a user's scopes.
|
|
144
|
+
|
|
145
|
+
An empty requirement means "authenticated is enough" and returns ``True``
|
|
146
|
+
under either mode -- including ``match="any"``, where a strict reading of
|
|
147
|
+
"any" would return ``False``. Routes declare zero scopes to mean
|
|
148
|
+
authentication-only, and answering ``False`` there would lock out every such
|
|
149
|
+
route.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
granted: Every scope the user holds.
|
|
153
|
+
required: The scopes the route demands.
|
|
154
|
+
match: ``"all"`` (default) requires every scope; ``"any"`` requires one.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
``True`` when the requirement is satisfied.
|
|
158
|
+
"""
|
|
159
|
+
required_scopes = tuple(required)
|
|
160
|
+
if not required_scopes:
|
|
161
|
+
return True
|
|
162
|
+
held = tuple(granted)
|
|
163
|
+
if match == "any":
|
|
164
|
+
return any(has_scope(held, scope) for scope in required_scopes)
|
|
165
|
+
return all(has_scope(held, scope) for scope in required_scopes)
|
fastauth/cli/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The ``fastauth`` command-line interface (spec section 17).
|
|
2
|
+
|
|
3
|
+
The package is deliberately thin. Every command is a translation layer: it
|
|
4
|
+
resolves ``--config``, calls one already-tested API in the layers below, and
|
|
5
|
+
renders the result. No authentication, authorization or persistence logic lives
|
|
6
|
+
here, which is why ``fastauth.cli`` sits at the very top of the import-linter
|
|
7
|
+
layer contract and may import anything beneath it.
|
|
8
|
+
|
|
9
|
+
One module per command group, each exposing ``register(app)``:
|
|
10
|
+
|
|
11
|
+
============================ =========================================
|
|
12
|
+
:mod:`fastauth.cli.init` ``fastauth init``
|
|
13
|
+
:mod:`fastauth.cli.config_cmd` ``fastauth config validate``
|
|
14
|
+
:mod:`fastauth.cli.secret` ``fastauth secret generate``
|
|
15
|
+
:mod:`fastauth.cli.doctor` ``fastauth doctor``
|
|
16
|
+
:mod:`fastauth.cli.database` ``fastauth db ...``
|
|
17
|
+
:mod:`fastauth.cli.users` ``fastauth users ...``
|
|
18
|
+
:mod:`fastauth.cli.sessions` ``fastauth sessions ...``
|
|
19
|
+
:mod:`fastauth.cli.routes` ``fastauth routes``
|
|
20
|
+
============================ =========================================
|
|
21
|
+
|
|
22
|
+
Registration rather than a central command table is what let this be built
|
|
23
|
+
without a single file that every command group has to edit.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from fastauth.cli.app import app, main
|
|
29
|
+
|
|
30
|
+
__all__ = ["app", "main"]
|