unifi-api-server 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 (251) hide show
  1. unifi_api/__init__.py +1 -0
  2. unifi_api/__main__.py +7 -0
  3. unifi_api/_version.py +24 -0
  4. unifi_api/alembic/.gitkeep +0 -0
  5. unifi_api/alembic/env.py +64 -0
  6. unifi_api/alembic/script.py.mako +26 -0
  7. unifi_api/alembic/versions/0001_initial_schema.py +73 -0
  8. unifi_api/alembic/versions/0002_add_app_settings.py +52 -0
  9. unifi_api/alembic.ini +39 -0
  10. unifi_api/auth/__init__.py +1 -0
  11. unifi_api/auth/api_key.py +55 -0
  12. unifi_api/auth/cache.py +60 -0
  13. unifi_api/auth/middleware.py +98 -0
  14. unifi_api/auth/scopes.py +28 -0
  15. unifi_api/cli.py +317 -0
  16. unifi_api/config.py +96 -0
  17. unifi_api/config.yaml +10 -0
  18. unifi_api/db/__init__.py +1 -0
  19. unifi_api/db/crypto.py +71 -0
  20. unifi_api/db/engine.py +28 -0
  21. unifi_api/db/models.py +76 -0
  22. unifi_api/db/session.py +9 -0
  23. unifi_api/graphql/__init__.py +0 -0
  24. unifi_api/graphql/_naming.py +59 -0
  25. unifi_api/graphql/context.py +77 -0
  26. unifi_api/graphql/docgen.py +118 -0
  27. unifi_api/graphql/errors.py +49 -0
  28. unifi_api/graphql/permissions.py +36 -0
  29. unifi_api/graphql/pydantic_export.py +117 -0
  30. unifi_api/graphql/resolvers/__init__.py +0 -0
  31. unifi_api/graphql/resolvers/access.py +843 -0
  32. unifi_api/graphql/resolvers/network.py +3466 -0
  33. unifi_api/graphql/resolvers/protect.py +1088 -0
  34. unifi_api/graphql/schema.graphql +1600 -0
  35. unifi_api/graphql/schema.py +53 -0
  36. unifi_api/graphql/type_registry.py +58 -0
  37. unifi_api/graphql/type_registry_init.py +508 -0
  38. unifi_api/graphql/types/__init__.py +0 -0
  39. unifi_api/graphql/types/access/__init__.py +0 -0
  40. unifi_api/graphql/types/access/credentials.py +66 -0
  41. unifi_api/graphql/types/access/devices.py +76 -0
  42. unifi_api/graphql/types/access/doors.py +225 -0
  43. unifi_api/graphql/types/access/events.py +190 -0
  44. unifi_api/graphql/types/access/policies.py +102 -0
  45. unifi_api/graphql/types/access/schedules.py +77 -0
  46. unifi_api/graphql/types/access/system.py +108 -0
  47. unifi_api/graphql/types/access/users.py +109 -0
  48. unifi_api/graphql/types/access/visitors.py +75 -0
  49. unifi_api/graphql/types/network/__init__.py +0 -0
  50. unifi_api/graphql/types/network/acl.py +71 -0
  51. unifi_api/graphql/types/network/ap_group.py +65 -0
  52. unifi_api/graphql/types/network/client.py +172 -0
  53. unifi_api/graphql/types/network/client_group.py +97 -0
  54. unifi_api/graphql/types/network/content_filter.py +65 -0
  55. unifi_api/graphql/types/network/device.py +409 -0
  56. unifi_api/graphql/types/network/dns.py +64 -0
  57. unifi_api/graphql/types/network/dpi.py +100 -0
  58. unifi_api/graphql/types/network/event.py +84 -0
  59. unifi_api/graphql/types/network/firewall.py +134 -0
  60. unifi_api/graphql/types/network/network.py +99 -0
  61. unifi_api/graphql/types/network/oon.py +69 -0
  62. unifi_api/graphql/types/network/port_forward.py +72 -0
  63. unifi_api/graphql/types/network/qos.py +65 -0
  64. unifi_api/graphql/types/network/route.py +166 -0
  65. unifi_api/graphql/types/network/session.py +131 -0
  66. unifi_api/graphql/types/network/stat.py +107 -0
  67. unifi_api/graphql/types/network/switch.py +228 -0
  68. unifi_api/graphql/types/network/system.py +425 -0
  69. unifi_api/graphql/types/network/voucher.py +69 -0
  70. unifi_api/graphql/types/network/vpn.py +135 -0
  71. unifi_api/graphql/types/network/wlan.py +54 -0
  72. unifi_api/graphql/types/protect/__init__.py +0 -0
  73. unifi_api/graphql/types/protect/alarms.py +242 -0
  74. unifi_api/graphql/types/protect/cameras.py +280 -0
  75. unifi_api/graphql/types/protect/chimes.py +74 -0
  76. unifi_api/graphql/types/protect/events.py +246 -0
  77. unifi_api/graphql/types/protect/lights.py +71 -0
  78. unifi_api/graphql/types/protect/liveviews.py +138 -0
  79. unifi_api/graphql/types/protect/recordings.py +166 -0
  80. unifi_api/graphql/types/protect/sensors.py +89 -0
  81. unifi_api/graphql/types/protect/system.py +404 -0
  82. unifi_api/logging.py +89 -0
  83. unifi_api/routes/__init__.py +1 -0
  84. unifi_api/routes/actions.py +170 -0
  85. unifi_api/routes/admin/__init__.py +0 -0
  86. unifi_api/routes/admin/_common.py +27 -0
  87. unifi_api/routes/admin/audit.py +257 -0
  88. unifi_api/routes/admin/auth.py +31 -0
  89. unifi_api/routes/admin/controllers.py +263 -0
  90. unifi_api/routes/admin/dashboard.py +35 -0
  91. unifi_api/routes/admin/keys.py +110 -0
  92. unifi_api/routes/admin/logs.py +172 -0
  93. unifi_api/routes/admin/settings.py +124 -0
  94. unifi_api/routes/admin_data.py +73 -0
  95. unifi_api/routes/audit.py +152 -0
  96. unifi_api/routes/catalog.py +159 -0
  97. unifi_api/routes/controllers.py +186 -0
  98. unifi_api/routes/health.py +29 -0
  99. unifi_api/routes/resources/__init__.py +1 -0
  100. unifi_api/routes/resources/_common.py +60 -0
  101. unifi_api/routes/resources/access/__init__.py +1 -0
  102. unifi_api/routes/resources/access/credentials.py +118 -0
  103. unifi_api/routes/resources/access/devices.py +126 -0
  104. unifi_api/routes/resources/access/doors.py +226 -0
  105. unifi_api/routes/resources/access/events.py +245 -0
  106. unifi_api/routes/resources/access/policies.py +125 -0
  107. unifi_api/routes/resources/access/schedules.py +76 -0
  108. unifi_api/routes/resources/access/system.py +108 -0
  109. unifi_api/routes/resources/access/users.py +127 -0
  110. unifi_api/routes/resources/access/visitors.py +122 -0
  111. unifi_api/routes/resources/network/__init__.py +1 -0
  112. unifi_api/routes/resources/network/acl.py +127 -0
  113. unifi_api/routes/resources/network/ap_groups.py +129 -0
  114. unifi_api/routes/resources/network/blocked_clients.py +75 -0
  115. unifi_api/routes/resources/network/client_groups.py +127 -0
  116. unifi_api/routes/resources/network/clients.py +116 -0
  117. unifi_api/routes/resources/network/content_filters.py +127 -0
  118. unifi_api/routes/resources/network/devices.py +152 -0
  119. unifi_api/routes/resources/network/dns.py +129 -0
  120. unifi_api/routes/resources/network/dpi.py +158 -0
  121. unifi_api/routes/resources/network/events.py +238 -0
  122. unifi_api/routes/resources/network/firewall_groups.py +127 -0
  123. unifi_api/routes/resources/network/firewall_rules.py +121 -0
  124. unifi_api/routes/resources/network/firewall_zones.py +85 -0
  125. unifi_api/routes/resources/network/lldp.py +94 -0
  126. unifi_api/routes/resources/network/lookup.py +57 -0
  127. unifi_api/routes/resources/network/networks.py +110 -0
  128. unifi_api/routes/resources/network/oon.py +127 -0
  129. unifi_api/routes/resources/network/port_forwards.py +128 -0
  130. unifi_api/routes/resources/network/qos.py +127 -0
  131. unifi_api/routes/resources/network/rogue_aps.py +193 -0
  132. unifi_api/routes/resources/network/routes.py +273 -0
  133. unifi_api/routes/resources/network/snmp.py +54 -0
  134. unifi_api/routes/resources/network/speedtest.py +51 -0
  135. unifi_api/routes/resources/network/stats.py +335 -0
  136. unifi_api/routes/resources/network/switch.py +322 -0
  137. unifi_api/routes/resources/network/system.py +376 -0
  138. unifi_api/routes/resources/network/user_groups.py +129 -0
  139. unifi_api/routes/resources/network/vouchers.py +131 -0
  140. unifi_api/routes/resources/network/vpn.py +224 -0
  141. unifi_api/routes/resources/network/wireless.py +78 -0
  142. unifi_api/routes/resources/network/wlans.py +111 -0
  143. unifi_api/routes/resources/protect/__init__.py +1 -0
  144. unifi_api/routes/resources/protect/cameras.py +279 -0
  145. unifi_api/routes/resources/protect/chimes.py +80 -0
  146. unifi_api/routes/resources/protect/events.py +297 -0
  147. unifi_api/routes/resources/protect/lights.py +147 -0
  148. unifi_api/routes/resources/protect/liveviews.py +134 -0
  149. unifi_api/routes/resources/protect/recordings.py +195 -0
  150. unifi_api/routes/resources/protect/sensors.py +135 -0
  151. unifi_api/routes/resources/protect/system.py +337 -0
  152. unifi_api/routes/streams/__init__.py +0 -0
  153. unifi_api/routes/streams/access.py +57 -0
  154. unifi_api/routes/streams/access_per_door.py +59 -0
  155. unifi_api/routes/streams/network.py +57 -0
  156. unifi_api/routes/streams/network_per_device.py +59 -0
  157. unifi_api/routes/streams/protect.py +57 -0
  158. unifi_api/routes/streams/protect_per_camera.py +59 -0
  159. unifi_api/serializers/__init__.py +1 -0
  160. unifi_api/serializers/_base.py +136 -0
  161. unifi_api/serializers/_registry.py +133 -0
  162. unifi_api/serializers/access/__init__.py +1 -0
  163. unifi_api/serializers/access/credentials.py +37 -0
  164. unifi_api/serializers/access/devices.py +36 -0
  165. unifi_api/serializers/access/doors.py +41 -0
  166. unifi_api/serializers/access/events.py +88 -0
  167. unifi_api/serializers/access/policies.py +37 -0
  168. unifi_api/serializers/access/visitors.py +37 -0
  169. unifi_api/serializers/network/__init__.py +1 -0
  170. unifi_api/serializers/network/acl.py +46 -0
  171. unifi_api/serializers/network/ap_groups.py +41 -0
  172. unifi_api/serializers/network/client_groups.py +53 -0
  173. unifi_api/serializers/network/clients.py +43 -0
  174. unifi_api/serializers/network/content_filter.py +43 -0
  175. unifi_api/serializers/network/devices.py +55 -0
  176. unifi_api/serializers/network/dns.py +35 -0
  177. unifi_api/serializers/network/dpi.py +10 -0
  178. unifi_api/serializers/network/events.py +71 -0
  179. unifi_api/serializers/network/firewall_rules.py +53 -0
  180. unifi_api/serializers/network/networks.py +34 -0
  181. unifi_api/serializers/network/oon.py +51 -0
  182. unifi_api/serializers/network/port_forwards.py +48 -0
  183. unifi_api/serializers/network/qos.py +47 -0
  184. unifi_api/serializers/network/routes.py +36 -0
  185. unifi_api/serializers/network/sessions.py +13 -0
  186. unifi_api/serializers/network/snmp.py +39 -0
  187. unifi_api/serializers/network/stats.py +21 -0
  188. unifi_api/serializers/network/switch.py +50 -0
  189. unifi_api/serializers/network/system.py +43 -0
  190. unifi_api/serializers/network/vouchers.py +36 -0
  191. unifi_api/serializers/network/vpn.py +33 -0
  192. unifi_api/serializers/network/wlans.py +33 -0
  193. unifi_api/serializers/protect/__init__.py +1 -0
  194. unifi_api/serializers/protect/alarms.py +39 -0
  195. unifi_api/serializers/protect/cameras.py +48 -0
  196. unifi_api/serializers/protect/chimes.py +47 -0
  197. unifi_api/serializers/protect/events.py +84 -0
  198. unifi_api/serializers/protect/lights.py +31 -0
  199. unifi_api/serializers/protect/liveviews.py +39 -0
  200. unifi_api/serializers/protect/recordings.py +38 -0
  201. unifi_api/serializers/protect/sensors.py +12 -0
  202. unifi_api/serializers/protect/system.py +15 -0
  203. unifi_api/server.py +446 -0
  204. unifi_api/services/__init__.py +1 -0
  205. unifi_api/services/actions.py +312 -0
  206. unifi_api/services/audit.py +80 -0
  207. unifi_api/services/audit_pruner.py +60 -0
  208. unifi_api/services/capability_cache.py +52 -0
  209. unifi_api/services/controllers.py +194 -0
  210. unifi_api/services/diagnostics.py +96 -0
  211. unifi_api/services/dispatch_overrides.py +98 -0
  212. unifi_api/services/log_reader.py +66 -0
  213. unifi_api/services/managers.py +383 -0
  214. unifi_api/services/manifest.py +168 -0
  215. unifi_api/services/pagination.py +75 -0
  216. unifi_api/services/pydantic_models.py +33 -0
  217. unifi_api/services/resource_routes.py +41 -0
  218. unifi_api/services/settings.py +82 -0
  219. unifi_api/services/stream_generator.py +75 -0
  220. unifi_api/services/streams.py +67 -0
  221. unifi_api/static/admin/README.md +20 -0
  222. unifi_api/static/admin/admin.css +23 -0
  223. unifi_api/static/admin/admin.js +31 -0
  224. unifi_api/static/admin/htmx-sse.min.js +290 -0
  225. unifi_api/static/admin/htmx.min.js +1 -0
  226. unifi_api/static/admin/pico.min.css +4 -0
  227. unifi_api/templates/admin/_diagnostics_fragment.html +26 -0
  228. unifi_api/templates/admin/_layout.html +34 -0
  229. unifi_api/templates/admin/_macros.html +31 -0
  230. unifi_api/templates/admin/audit/_filters.html +48 -0
  231. unifi_api/templates/admin/audit/_row.html +8 -0
  232. unifi_api/templates/admin/audit/_rows.html +14 -0
  233. unifi_api/templates/admin/audit/list.html +29 -0
  234. unifi_api/templates/admin/controllers/_form.html +55 -0
  235. unifi_api/templates/admin/controllers/_probe_result.html +16 -0
  236. unifi_api/templates/admin/controllers/_row.html +20 -0
  237. unifi_api/templates/admin/controllers/_table.html +3 -0
  238. unifi_api/templates/admin/controllers/list.html +33 -0
  239. unifi_api/templates/admin/dashboard.html +8 -0
  240. unifi_api/templates/admin/keys/_created_modal.html +13 -0
  241. unifi_api/templates/admin/keys/_row.html +23 -0
  242. unifi_api/templates/admin/keys/_table.html +3 -0
  243. unifi_api/templates/admin/keys/list.html +48 -0
  244. unifi_api/templates/admin/login.html +51 -0
  245. unifi_api/templates/admin/settings/_form.html +56 -0
  246. unifi_api/templates/admin/settings/_prune_result.html +5 -0
  247. unifi_api/templates/admin/settings/page.html +22 -0
  248. unifi_api_server-0.1.0.dist-info/METADATA +160 -0
  249. unifi_api_server-0.1.0.dist-info/RECORD +251 -0
  250. unifi_api_server-0.1.0.dist-info/WHEEL +4 -0
  251. unifi_api_server-0.1.0.dist-info/entry_points.txt +2 -0
unifi_api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """UniFi rich HTTP API service."""
unifi_api/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Entry point for `python -m unifi_api`."""
2
+
3
+ from unifi_api.cli import app
4
+
5
+
6
+ if __name__ == "__main__":
7
+ app()
unifi_api/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
File without changes
@@ -0,0 +1,64 @@
1
+ """Alembic env for unifi-api.
2
+
3
+ The DB file is unencrypted at the engine layer (sensitive columns use
4
+ AES-GCM at the application layer per unifi_api.db.crypto). The DB path is
5
+ resolvable from config or via -x db_path=... on the alembic command line.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from alembic import context
16
+ from sqlalchemy.ext.asyncio import AsyncEngine
17
+
18
+ # Make unifi_api importable when alembic runs standalone
19
+ _HERE = Path(__file__).resolve().parent
20
+ _SRC = _HERE.parent / "src"
21
+ if _SRC.exists() and str(_SRC) not in sys.path:
22
+ sys.path.insert(0, str(_SRC))
23
+
24
+ from unifi_api.db.engine import create_engine # noqa: E402
25
+ from unifi_api.db.models import Base # noqa: E402
26
+
27
+
28
+ target_metadata = Base.metadata
29
+
30
+
31
+ def _resolve_db_path() -> Path:
32
+ cli_args = context.get_x_argument(as_dictionary=True)
33
+ if "db_path" in cli_args:
34
+ return Path(cli_args["db_path"])
35
+ env_path = os.environ.get("UNIFI_API_DB_PATH")
36
+ if env_path:
37
+ return Path(env_path)
38
+ return Path("./state.db")
39
+
40
+
41
+ def run_migrations_offline() -> None:
42
+ raise RuntimeError(
43
+ "Offline migration mode is not supported; the async engine requires "
44
+ "a live connection."
45
+ )
46
+
47
+
48
+ async def run_migrations_online() -> None:
49
+ engine: AsyncEngine = create_engine(_resolve_db_path())
50
+ async with engine.connect() as conn:
51
+ await conn.run_sync(_run_sync_migrations)
52
+ await engine.dispose()
53
+
54
+
55
+ def _run_sync_migrations(connection):
56
+ context.configure(connection=connection, target_metadata=target_metadata)
57
+ with context.begin_transaction():
58
+ context.run_migrations()
59
+
60
+
61
+ if context.is_offline_mode():
62
+ run_migrations_offline()
63
+ else:
64
+ asyncio.run(run_migrations_online())
@@ -0,0 +1,26 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ ${upgrades if upgrades else "pass"}
23
+
24
+
25
+ def downgrade() -> None:
26
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,73 @@
1
+ """initial schema
2
+
3
+ Revision ID: 0001
4
+ Revises:
5
+ Create Date: 2026-04-28
6
+ """
7
+
8
+ from typing import Sequence, Union
9
+
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+
14
+ revision: str = "0001"
15
+ down_revision: Union[str, None] = None
16
+ branch_labels: Union[str, Sequence[str], None] = None
17
+ depends_on: Union[str, Sequence[str], None] = None
18
+
19
+
20
+ def upgrade() -> None:
21
+ op.create_table(
22
+ "schema_version",
23
+ sa.Column("version", sa.Integer, primary_key=True),
24
+ sa.Column("applied_at", sa.DateTime(timezone=True), nullable=False),
25
+ )
26
+ op.create_table(
27
+ "api_keys",
28
+ sa.Column("id", sa.String(36), primary_key=True),
29
+ sa.Column("prefix", sa.String(64), nullable=False, unique=True),
30
+ sa.Column("hash", sa.String(255), nullable=False),
31
+ sa.Column("scopes", sa.String(64), nullable=False),
32
+ sa.Column("name", sa.String(128), nullable=False),
33
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
34
+ sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
35
+ )
36
+ op.create_table(
37
+ "controllers",
38
+ sa.Column("id", sa.String(36), primary_key=True),
39
+ sa.Column("name", sa.String(128), nullable=False),
40
+ sa.Column("base_url", sa.String(255), nullable=False),
41
+ sa.Column("product_kinds", sa.String(64), nullable=False),
42
+ sa.Column("credentials_blob", sa.LargeBinary, nullable=False),
43
+ sa.Column("verify_tls", sa.Boolean, nullable=False, server_default=sa.text("1")),
44
+ sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.text("0")),
45
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
46
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
47
+ )
48
+ op.create_table(
49
+ "sessions",
50
+ sa.Column("id", sa.String(36), primary_key=True),
51
+ sa.Column("api_key_id", sa.String(36), sa.ForeignKey("api_keys.id"), nullable=False),
52
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
53
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
54
+ )
55
+ op.create_table(
56
+ "audit_log",
57
+ sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
58
+ sa.Column("ts", sa.DateTime(timezone=True), nullable=False),
59
+ sa.Column("key_id_prefix", sa.String(64), nullable=False),
60
+ sa.Column("controller", sa.String(36), nullable=True),
61
+ sa.Column("target", sa.String(128), nullable=False),
62
+ sa.Column("outcome", sa.String(32), nullable=False),
63
+ sa.Column("error_kind", sa.String(64), nullable=True),
64
+ sa.Column("detail", sa.Text, nullable=True),
65
+ )
66
+
67
+
68
+ def downgrade() -> None:
69
+ op.drop_table("audit_log")
70
+ op.drop_table("sessions")
71
+ op.drop_table("controllers")
72
+ op.drop_table("api_keys")
73
+ op.drop_table("schema_version")
@@ -0,0 +1,52 @@
1
+ """add app_settings table
2
+
3
+ Revision ID: 0002
4
+ Revises: 0001
5
+ Create Date: 2026-04-30
6
+ """
7
+
8
+ from datetime import datetime, timezone
9
+ from typing import Sequence, Union
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+
15
+ revision: str = "0002"
16
+ down_revision: Union[str, None] = "0001"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ op.create_table(
23
+ "app_settings",
24
+ sa.Column("key", sa.String(128), primary_key=True),
25
+ sa.Column("value", sa.Text, nullable=False),
26
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
27
+ )
28
+ defaults = [
29
+ ("audit.retention.max_age_days", "90"),
30
+ ("audit.retention.max_rows", "1000000"),
31
+ ("audit.retention.enabled", "true"),
32
+ ("audit.retention.prune_interval_hours", "6"),
33
+ ("logs.file.enabled", "true"),
34
+ ("logs.file.path", "state/api.log"),
35
+ ("logs.file.max_bytes", "10485760"),
36
+ ("logs.file.backup_count", "5"),
37
+ ("logs.file.level", "INFO"),
38
+ ("theme.default", "auto"),
39
+ ]
40
+ op.bulk_insert(
41
+ sa.table(
42
+ "app_settings",
43
+ sa.column("key", sa.String),
44
+ sa.column("value", sa.Text),
45
+ sa.column("updated_at", sa.DateTime(timezone=True)),
46
+ ),
47
+ [{"key": k, "value": v, "updated_at": datetime.now(timezone.utc)} for k, v in defaults],
48
+ )
49
+
50
+
51
+ def downgrade() -> None:
52
+ op.drop_table("app_settings")
unifi_api/alembic.ini ADDED
@@ -0,0 +1,39 @@
1
+ [alembic]
2
+ script_location = alembic
3
+ prepend_sys_path = .
4
+ version_path_separator = os
5
+ sqlalchemy.url = sqlite+aiosqlite:///./state.db
6
+
7
+ [loggers]
8
+ keys = root,sqlalchemy,alembic
9
+
10
+ [handlers]
11
+ keys = console
12
+
13
+ [formatters]
14
+ keys = generic
15
+
16
+ [logger_root]
17
+ level = WARN
18
+ handlers = console
19
+ qualname =
20
+
21
+ [logger_sqlalchemy]
22
+ level = WARN
23
+ handlers =
24
+ qualname = sqlalchemy.engine
25
+
26
+ [logger_alembic]
27
+ level = INFO
28
+ handlers =
29
+ qualname = alembic
30
+
31
+ [handler_console]
32
+ class = StreamHandler
33
+ args = (sys.stderr,)
34
+ level = NOTSET
35
+ formatter = generic
36
+
37
+ [formatter_generic]
38
+ format = %(levelname)-5.5s [%(name)s] %(message)s
39
+ datefmt = %H:%M:%S
@@ -0,0 +1 @@
1
+ """Authentication module."""
@@ -0,0 +1,55 @@
1
+ """API key generation, format validation, and argon2id hashing.
2
+
3
+ Format: unifi_<env>_<22-char-base32>
4
+ Env is "live" or "test". The 22-char body is base32-encoded random bytes.
5
+ The "prefix" exposed in audit logs is the first 15 chars (env + first 4 random).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import secrets
12
+ from base64 import b32encode
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+
16
+ from argon2 import PasswordHasher
17
+ from argon2.exceptions import VerifyMismatchError
18
+
19
+
20
+ KEY_PATTERN = re.compile(r"^unifi_(live|test)_[A-Z2-7]{22}$")
21
+ KEY_PREFIX_LEN = 15
22
+ _HASHER = PasswordHasher()
23
+
24
+
25
+ class ApiKeyEnv(str, Enum):
26
+ LIVE = "live"
27
+ TEST = "test"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ApiKeyMaterial:
32
+ plaintext: str
33
+ prefix: str
34
+
35
+
36
+ def generate_key(env: ApiKeyEnv = ApiKeyEnv.LIVE) -> ApiKeyMaterial:
37
+ body = b32encode(secrets.token_bytes(14)).decode("ascii").rstrip("=")[:22]
38
+ plaintext = f"unifi_{env.value}_{body}"
39
+ prefix = plaintext[:KEY_PREFIX_LEN]
40
+ return ApiKeyMaterial(plaintext=plaintext, prefix=prefix)
41
+
42
+
43
+ def hash_key(plaintext: str) -> str:
44
+ if not KEY_PATTERN.fullmatch(plaintext):
45
+ raise ValueError("invalid key format")
46
+ return _HASHER.hash(plaintext)
47
+
48
+
49
+ def verify_key(plaintext: str, digest: str) -> bool:
50
+ if not KEY_PATTERN.fullmatch(plaintext):
51
+ raise ValueError("invalid key format")
52
+ try:
53
+ return _HASHER.verify(digest, plaintext)
54
+ except VerifyMismatchError:
55
+ return False
@@ -0,0 +1,60 @@
1
+ """Argon2 verify cache.
2
+
3
+ Bounded LRU keyed by SHA-256(plaintext_token), value = (api_key_id, scopes,
4
+ fetched_at). Cache hit lets the auth path skip ~50-100ms argon2id verify.
5
+
6
+ Process-global singleton initialized in server.create_app and wired into
7
+ app.state. NOT an asyncio.Lock-guarded structure - Python dict + collections.OrderedDict
8
+ gives us O(1) LRU and our access pattern is single-process, lock-free safe under CPython
9
+ GIL for these atomic operations. If we move to a multi-process deployment later, this
10
+ becomes a Redis cache with the same interface.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import time
17
+ from collections import OrderedDict
18
+ from dataclasses import dataclass
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class CachedKey:
23
+ api_key_id: str
24
+ scopes: str
25
+ fetched_at: float
26
+
27
+
28
+ def _hash_plaintext(plaintext: str) -> str:
29
+ return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
30
+
31
+
32
+ class ArgonVerifyCache:
33
+ def __init__(self, max_size: int = 1024, ttl_seconds: int = 60) -> None:
34
+ self._max_size = max_size
35
+ self._ttl = ttl_seconds
36
+ self._data: OrderedDict[str, CachedKey] = OrderedDict()
37
+
38
+ def get(self, plaintext: str) -> CachedKey | None:
39
+ h = _hash_plaintext(plaintext)
40
+ entry = self._data.get(h)
41
+ if entry is None:
42
+ return None
43
+ if time.time() - entry.fetched_at > self._ttl:
44
+ del self._data[h]
45
+ return None
46
+ self._data.move_to_end(h)
47
+ return entry
48
+
49
+ def put(self, plaintext: str, value: CachedKey) -> None:
50
+ h = _hash_plaintext(plaintext)
51
+ self._data[h] = value
52
+ self._data.move_to_end(h)
53
+ while len(self._data) > self._max_size:
54
+ self._data.popitem(last=False)
55
+
56
+ def invalidate(self, api_key_id: str) -> None:
57
+ """Drop all entries whose api_key_id matches."""
58
+ to_remove = [h for h, v in self._data.items() if v.api_key_id == api_key_id]
59
+ for h in to_remove:
60
+ del self._data[h]
@@ -0,0 +1,98 @@
1
+ """Bearer token auth middleware as a FastAPI dependency factory.
2
+
3
+ `_authenticate` resolves a bearer token to an ApiKey row (or raises 401).
4
+ `require_scope(scope)` returns a FastAPI dependency that authenticates and
5
+ then enforces the requested scope (raises 403 on insufficient scope).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Callable
12
+
13
+ from fastapi import HTTPException, Request, status
14
+ from sqlalchemy import select
15
+
16
+ from unifi_api.auth.api_key import KEY_PATTERN, KEY_PREFIX_LEN, verify_key
17
+ from unifi_api.auth.cache import ArgonVerifyCache, CachedKey
18
+ from unifi_api.auth.scopes import Scope, parse_scopes, scope_allows
19
+ from unifi_api.db.models import ApiKey
20
+ from unifi_api.logging import key_id_prefix_ctx
21
+ from unifi_api.services.audit import write_audit
22
+
23
+
24
+ async def _audit_denial(app, *, key_id_prefix: str, target: str, error_kind: str) -> None:
25
+ """Best-effort denial audit. Uses a fresh session so middleware doesn't depend on caller transaction state."""
26
+ sm = app.state.sessionmaker
27
+ async with sm() as session:
28
+ await write_audit(
29
+ session, key_id_prefix=key_id_prefix,
30
+ controller=None, target=target,
31
+ outcome="denied", error_kind=error_kind,
32
+ )
33
+ await session.commit()
34
+
35
+
36
+ async def _authenticate(request: Request) -> ApiKey:
37
+ target = f"{request.method} {request.url.path}"
38
+ auth_header = request.headers.get("authorization", "")
39
+ if not auth_header.lower().startswith("bearer "):
40
+ await _audit_denial(request.app, key_id_prefix="(none)", target=target, error_kind="missing_bearer")
41
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token")
42
+ plaintext = auth_header[len("Bearer "):].strip()
43
+
44
+ if not KEY_PATTERN.fullmatch(plaintext):
45
+ await _audit_denial(request.app, key_id_prefix=plaintext[:KEY_PREFIX_LEN] or "(short)",
46
+ target=target, error_kind="malformed_token")
47
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="malformed token")
48
+
49
+ cache: ArgonVerifyCache = request.app.state.argon_cache
50
+ cached = cache.get(plaintext)
51
+ sm = request.app.state.sessionmaker
52
+
53
+ if cached is not None:
54
+ # Cache hit — confirm not revoked
55
+ async with sm() as session:
56
+ row = (await session.execute(
57
+ select(ApiKey).where(ApiKey.id == cached.api_key_id)
58
+ )).scalar_one_or_none()
59
+ if row is None or row.revoked_at is not None:
60
+ cache.invalidate(cached.api_key_id)
61
+ await _audit_denial(request.app, key_id_prefix=plaintext[:KEY_PREFIX_LEN],
62
+ target=target, error_kind="unknown_token")
63
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unknown token")
64
+ key_id_prefix_ctx.set(row.prefix)
65
+ request.state.api_key_prefix = row.prefix
66
+ return row
67
+
68
+ # Cache miss — full argon2 verify path
69
+ prefix = plaintext[:KEY_PREFIX_LEN]
70
+ async with sm() as session:
71
+ row = (await session.execute(select(ApiKey).where(ApiKey.prefix == prefix))).scalar_one_or_none()
72
+ if row is None or row.revoked_at is not None:
73
+ await _audit_denial(request.app, key_id_prefix=prefix, target=target, error_kind="unknown_token")
74
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unknown token")
75
+ if not verify_key(plaintext, row.hash):
76
+ await _audit_denial(request.app, key_id_prefix=prefix, target=target, error_kind="invalid_token")
77
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
78
+
79
+ cache.put(plaintext, CachedKey(api_key_id=row.id, scopes=row.scopes, fetched_at=time.time()))
80
+ key_id_prefix_ctx.set(row.prefix)
81
+ request.state.api_key_prefix = row.prefix
82
+ return row
83
+
84
+
85
+ def require_scope(required: Scope) -> Callable:
86
+ async def _dep(request: Request) -> ApiKey:
87
+ api_key = await _authenticate(request)
88
+ held = parse_scopes(api_key.scopes)
89
+ if not scope_allows(held, required):
90
+ await _audit_denial(
91
+ request.app, key_id_prefix=api_key.prefix,
92
+ target=f"{request.method} {request.url.path}",
93
+ error_kind="insufficient_scope",
94
+ )
95
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="insufficient scope")
96
+ return api_key
97
+
98
+ return _dep
@@ -0,0 +1,28 @@
1
+ """Scope definitions and check helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+
7
+
8
+ class Scope(str, enum.Enum):
9
+ READ = "read"
10
+ WRITE = "write"
11
+ ADMIN = "admin"
12
+
13
+
14
+ def parse_scopes(s: str) -> frozenset[Scope]:
15
+ parts = [p.strip() for p in s.split(",") if p.strip()]
16
+ out: set[Scope] = set()
17
+ for p in parts:
18
+ try:
19
+ out.add(Scope(p))
20
+ except ValueError as e:
21
+ raise ValueError(f"unknown scope: {p}") from e
22
+ return frozenset(out)
23
+
24
+
25
+ def scope_allows(held: frozenset[Scope], required: Scope) -> bool:
26
+ if Scope.ADMIN in held:
27
+ return True
28
+ return required in held