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.
Files changed (119) hide show
  1. fastauth/__init__.py +29 -0
  2. fastauth/authorization/__init__.py +31 -0
  3. fastauth/authorization/roles.py +210 -0
  4. fastauth/authorization/scopes.py +165 -0
  5. fastauth/cli/__init__.py +30 -0
  6. fastauth/cli/_context.py +391 -0
  7. fastauth/cli/app.py +67 -0
  8. fastauth/cli/config_cmd.py +120 -0
  9. fastauth/cli/database.py +120 -0
  10. fastauth/cli/doctor.py +692 -0
  11. fastauth/cli/init.py +825 -0
  12. fastauth/cli/routes.py +98 -0
  13. fastauth/cli/secret.py +70 -0
  14. fastauth/cli/sessions.py +192 -0
  15. fastauth/cli/users.py +319 -0
  16. fastauth/clock.py +26 -0
  17. fastauth/config/__init__.py +84 -0
  18. fastauth/config/defaults.py +214 -0
  19. fastauth/config/duration.py +126 -0
  20. fastauth/config/environment.py +171 -0
  21. fastauth/config/loader.py +167 -0
  22. fastauth/config/models.py +717 -0
  23. fastauth/config/ratelimit.py +96 -0
  24. fastauth/config/validation.py +497 -0
  25. fastauth/container.py +589 -0
  26. fastauth/core.py +258 -0
  27. fastauth/db/__init__.py +44 -0
  28. fastauth/db/alembic.ini +90 -0
  29. fastauth/db/base.py +92 -0
  30. fastauth/db/engine.py +173 -0
  31. fastauth/db/mappers.py +74 -0
  32. fastauth/db/migration_runner.py +281 -0
  33. fastauth/db/migrations/__init__.py +184 -0
  34. fastauth/db/migrations/env.py +217 -0
  35. fastauth/db/migrations/script.py.mako +53 -0
  36. fastauth/db/migrations/versions/0001_initial_schema.py +158 -0
  37. fastauth/db/models.py +223 -0
  38. fastauth/db/stores/__init__.py +13 -0
  39. fastauth/db/stores/session_store.py +279 -0
  40. fastauth/db/stores/user_store.py +219 -0
  41. fastauth/db/types.py +101 -0
  42. fastauth/db/uow.py +118 -0
  43. fastauth/dependencies.py +159 -0
  44. fastauth/events.py +68 -0
  45. fastauth/exceptions.py +271 -0
  46. fastauth/installer.py +440 -0
  47. fastauth/mongo/__init__.py +194 -0
  48. fastauth/mongo/codec.py +104 -0
  49. fastauth/mongo/database.py +98 -0
  50. fastauth/mongo/documents.py +160 -0
  51. fastauth/mongo/indexes.py +111 -0
  52. fastauth/mongo/stores/__init__.py +13 -0
  53. fastauth/mongo/stores/session_store.py +291 -0
  54. fastauth/mongo/stores/user_store.py +310 -0
  55. fastauth/ownership/__init__.py +45 -0
  56. fastauth/ownership/_predicates.py +144 -0
  57. fastauth/ownership/admin.py +163 -0
  58. fastauth/ownership/mixins.py +120 -0
  59. fastauth/ownership/repository.py +429 -0
  60. fastauth/protocols/__init__.py +42 -0
  61. fastauth/protocols/clock.py +22 -0
  62. fastauth/protocols/events.py +28 -0
  63. fastauth/protocols/passwords.py +35 -0
  64. fastauth/protocols/rate_limit.py +25 -0
  65. fastauth/protocols/sessions.py +76 -0
  66. fastauth/protocols/templates.py +25 -0
  67. fastauth/protocols/tokens.py +66 -0
  68. fastauth/protocols/transport.py +49 -0
  69. fastauth/protocols/uow.py +88 -0
  70. fastauth/protocols/users.py +75 -0
  71. fastauth/py.typed +0 -0
  72. fastauth/routes/__init__.py +1 -0
  73. fastauth/routes/api.py +592 -0
  74. fastauth/routes/errors.py +239 -0
  75. fastauth/routes/schemas.py +256 -0
  76. fastauth/routes/table.py +59 -0
  77. fastauth/routes/ui.py +771 -0
  78. fastauth/security/__init__.py +51 -0
  79. fastauth/security/cookies.py +133 -0
  80. fastauth/security/csrf.py +135 -0
  81. fastauth/security/emails.py +106 -0
  82. fastauth/security/headers.py +144 -0
  83. fastauth/security/passwords.py +183 -0
  84. fastauth/security/rate_limit.py +127 -0
  85. fastauth/security/redirects.py +157 -0
  86. fastauth/security/secrets.py +314 -0
  87. fastauth/security/tokens.py +367 -0
  88. fastauth/services/__init__.py +47 -0
  89. fastauth/services/access.py +222 -0
  90. fastauth/services/credentials.py +125 -0
  91. fastauth/services/event_bus.py +125 -0
  92. fastauth/services/flows/__init__.py +23 -0
  93. fastauth/services/flows/login.py +238 -0
  94. fastauth/services/flows/logout.py +120 -0
  95. fastauth/services/flows/refresh.py +214 -0
  96. fastauth/services/flows/signup.py +216 -0
  97. fastauth/services/identity.py +134 -0
  98. fastauth/services/sessions.py +305 -0
  99. fastauth/services/tokens.py +274 -0
  100. fastauth/testing/__init__.py +69 -0
  101. fastauth/testing/clock.py +70 -0
  102. fastauth/testing/sessions.py +498 -0
  103. fastauth/testing/users.py +384 -0
  104. fastauth/transport/__init__.py +15 -0
  105. fastauth/transport/extractors.py +117 -0
  106. fastauth/types.py +284 -0
  107. fastauth/ui/__init__.py +15 -0
  108. fastauth/ui/renderer.py +265 -0
  109. fastauth/ui/static/fastauth.css +409 -0
  110. fastauth/ui/templates/account.html +119 -0
  111. fastauth/ui/templates/base.html +44 -0
  112. fastauth/ui/templates/error.html +37 -0
  113. fastauth/ui/templates/login.html +63 -0
  114. fastauth/ui/templates/signup.html +107 -0
  115. pyfastauth-0.1.0.dist-info/METADATA +359 -0
  116. pyfastauth-0.1.0.dist-info/RECORD +119 -0
  117. pyfastauth-0.1.0.dist-info/WHEEL +4 -0
  118. pyfastauth-0.1.0.dist-info/entry_points.txt +2 -0
  119. 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)
@@ -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"]