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.
- unifi_api/__init__.py +1 -0
- unifi_api/__main__.py +7 -0
- unifi_api/_version.py +24 -0
- unifi_api/alembic/.gitkeep +0 -0
- unifi_api/alembic/env.py +64 -0
- unifi_api/alembic/script.py.mako +26 -0
- unifi_api/alembic/versions/0001_initial_schema.py +73 -0
- unifi_api/alembic/versions/0002_add_app_settings.py +52 -0
- unifi_api/alembic.ini +39 -0
- unifi_api/auth/__init__.py +1 -0
- unifi_api/auth/api_key.py +55 -0
- unifi_api/auth/cache.py +60 -0
- unifi_api/auth/middleware.py +98 -0
- unifi_api/auth/scopes.py +28 -0
- unifi_api/cli.py +317 -0
- unifi_api/config.py +96 -0
- unifi_api/config.yaml +10 -0
- unifi_api/db/__init__.py +1 -0
- unifi_api/db/crypto.py +71 -0
- unifi_api/db/engine.py +28 -0
- unifi_api/db/models.py +76 -0
- unifi_api/db/session.py +9 -0
- unifi_api/graphql/__init__.py +0 -0
- unifi_api/graphql/_naming.py +59 -0
- unifi_api/graphql/context.py +77 -0
- unifi_api/graphql/docgen.py +118 -0
- unifi_api/graphql/errors.py +49 -0
- unifi_api/graphql/permissions.py +36 -0
- unifi_api/graphql/pydantic_export.py +117 -0
- unifi_api/graphql/resolvers/__init__.py +0 -0
- unifi_api/graphql/resolvers/access.py +843 -0
- unifi_api/graphql/resolvers/network.py +3466 -0
- unifi_api/graphql/resolvers/protect.py +1088 -0
- unifi_api/graphql/schema.graphql +1600 -0
- unifi_api/graphql/schema.py +53 -0
- unifi_api/graphql/type_registry.py +58 -0
- unifi_api/graphql/type_registry_init.py +508 -0
- unifi_api/graphql/types/__init__.py +0 -0
- unifi_api/graphql/types/access/__init__.py +0 -0
- unifi_api/graphql/types/access/credentials.py +66 -0
- unifi_api/graphql/types/access/devices.py +76 -0
- unifi_api/graphql/types/access/doors.py +225 -0
- unifi_api/graphql/types/access/events.py +190 -0
- unifi_api/graphql/types/access/policies.py +102 -0
- unifi_api/graphql/types/access/schedules.py +77 -0
- unifi_api/graphql/types/access/system.py +108 -0
- unifi_api/graphql/types/access/users.py +109 -0
- unifi_api/graphql/types/access/visitors.py +75 -0
- unifi_api/graphql/types/network/__init__.py +0 -0
- unifi_api/graphql/types/network/acl.py +71 -0
- unifi_api/graphql/types/network/ap_group.py +65 -0
- unifi_api/graphql/types/network/client.py +172 -0
- unifi_api/graphql/types/network/client_group.py +97 -0
- unifi_api/graphql/types/network/content_filter.py +65 -0
- unifi_api/graphql/types/network/device.py +409 -0
- unifi_api/graphql/types/network/dns.py +64 -0
- unifi_api/graphql/types/network/dpi.py +100 -0
- unifi_api/graphql/types/network/event.py +84 -0
- unifi_api/graphql/types/network/firewall.py +134 -0
- unifi_api/graphql/types/network/network.py +99 -0
- unifi_api/graphql/types/network/oon.py +69 -0
- unifi_api/graphql/types/network/port_forward.py +72 -0
- unifi_api/graphql/types/network/qos.py +65 -0
- unifi_api/graphql/types/network/route.py +166 -0
- unifi_api/graphql/types/network/session.py +131 -0
- unifi_api/graphql/types/network/stat.py +107 -0
- unifi_api/graphql/types/network/switch.py +228 -0
- unifi_api/graphql/types/network/system.py +425 -0
- unifi_api/graphql/types/network/voucher.py +69 -0
- unifi_api/graphql/types/network/vpn.py +135 -0
- unifi_api/graphql/types/network/wlan.py +54 -0
- unifi_api/graphql/types/protect/__init__.py +0 -0
- unifi_api/graphql/types/protect/alarms.py +242 -0
- unifi_api/graphql/types/protect/cameras.py +280 -0
- unifi_api/graphql/types/protect/chimes.py +74 -0
- unifi_api/graphql/types/protect/events.py +246 -0
- unifi_api/graphql/types/protect/lights.py +71 -0
- unifi_api/graphql/types/protect/liveviews.py +138 -0
- unifi_api/graphql/types/protect/recordings.py +166 -0
- unifi_api/graphql/types/protect/sensors.py +89 -0
- unifi_api/graphql/types/protect/system.py +404 -0
- unifi_api/logging.py +89 -0
- unifi_api/routes/__init__.py +1 -0
- unifi_api/routes/actions.py +170 -0
- unifi_api/routes/admin/__init__.py +0 -0
- unifi_api/routes/admin/_common.py +27 -0
- unifi_api/routes/admin/audit.py +257 -0
- unifi_api/routes/admin/auth.py +31 -0
- unifi_api/routes/admin/controllers.py +263 -0
- unifi_api/routes/admin/dashboard.py +35 -0
- unifi_api/routes/admin/keys.py +110 -0
- unifi_api/routes/admin/logs.py +172 -0
- unifi_api/routes/admin/settings.py +124 -0
- unifi_api/routes/admin_data.py +73 -0
- unifi_api/routes/audit.py +152 -0
- unifi_api/routes/catalog.py +159 -0
- unifi_api/routes/controllers.py +186 -0
- unifi_api/routes/health.py +29 -0
- unifi_api/routes/resources/__init__.py +1 -0
- unifi_api/routes/resources/_common.py +60 -0
- unifi_api/routes/resources/access/__init__.py +1 -0
- unifi_api/routes/resources/access/credentials.py +118 -0
- unifi_api/routes/resources/access/devices.py +126 -0
- unifi_api/routes/resources/access/doors.py +226 -0
- unifi_api/routes/resources/access/events.py +245 -0
- unifi_api/routes/resources/access/policies.py +125 -0
- unifi_api/routes/resources/access/schedules.py +76 -0
- unifi_api/routes/resources/access/system.py +108 -0
- unifi_api/routes/resources/access/users.py +127 -0
- unifi_api/routes/resources/access/visitors.py +122 -0
- unifi_api/routes/resources/network/__init__.py +1 -0
- unifi_api/routes/resources/network/acl.py +127 -0
- unifi_api/routes/resources/network/ap_groups.py +129 -0
- unifi_api/routes/resources/network/blocked_clients.py +75 -0
- unifi_api/routes/resources/network/client_groups.py +127 -0
- unifi_api/routes/resources/network/clients.py +116 -0
- unifi_api/routes/resources/network/content_filters.py +127 -0
- unifi_api/routes/resources/network/devices.py +152 -0
- unifi_api/routes/resources/network/dns.py +129 -0
- unifi_api/routes/resources/network/dpi.py +158 -0
- unifi_api/routes/resources/network/events.py +238 -0
- unifi_api/routes/resources/network/firewall_groups.py +127 -0
- unifi_api/routes/resources/network/firewall_rules.py +121 -0
- unifi_api/routes/resources/network/firewall_zones.py +85 -0
- unifi_api/routes/resources/network/lldp.py +94 -0
- unifi_api/routes/resources/network/lookup.py +57 -0
- unifi_api/routes/resources/network/networks.py +110 -0
- unifi_api/routes/resources/network/oon.py +127 -0
- unifi_api/routes/resources/network/port_forwards.py +128 -0
- unifi_api/routes/resources/network/qos.py +127 -0
- unifi_api/routes/resources/network/rogue_aps.py +193 -0
- unifi_api/routes/resources/network/routes.py +273 -0
- unifi_api/routes/resources/network/snmp.py +54 -0
- unifi_api/routes/resources/network/speedtest.py +51 -0
- unifi_api/routes/resources/network/stats.py +335 -0
- unifi_api/routes/resources/network/switch.py +322 -0
- unifi_api/routes/resources/network/system.py +376 -0
- unifi_api/routes/resources/network/user_groups.py +129 -0
- unifi_api/routes/resources/network/vouchers.py +131 -0
- unifi_api/routes/resources/network/vpn.py +224 -0
- unifi_api/routes/resources/network/wireless.py +78 -0
- unifi_api/routes/resources/network/wlans.py +111 -0
- unifi_api/routes/resources/protect/__init__.py +1 -0
- unifi_api/routes/resources/protect/cameras.py +279 -0
- unifi_api/routes/resources/protect/chimes.py +80 -0
- unifi_api/routes/resources/protect/events.py +297 -0
- unifi_api/routes/resources/protect/lights.py +147 -0
- unifi_api/routes/resources/protect/liveviews.py +134 -0
- unifi_api/routes/resources/protect/recordings.py +195 -0
- unifi_api/routes/resources/protect/sensors.py +135 -0
- unifi_api/routes/resources/protect/system.py +337 -0
- unifi_api/routes/streams/__init__.py +0 -0
- unifi_api/routes/streams/access.py +57 -0
- unifi_api/routes/streams/access_per_door.py +59 -0
- unifi_api/routes/streams/network.py +57 -0
- unifi_api/routes/streams/network_per_device.py +59 -0
- unifi_api/routes/streams/protect.py +57 -0
- unifi_api/routes/streams/protect_per_camera.py +59 -0
- unifi_api/serializers/__init__.py +1 -0
- unifi_api/serializers/_base.py +136 -0
- unifi_api/serializers/_registry.py +133 -0
- unifi_api/serializers/access/__init__.py +1 -0
- unifi_api/serializers/access/credentials.py +37 -0
- unifi_api/serializers/access/devices.py +36 -0
- unifi_api/serializers/access/doors.py +41 -0
- unifi_api/serializers/access/events.py +88 -0
- unifi_api/serializers/access/policies.py +37 -0
- unifi_api/serializers/access/visitors.py +37 -0
- unifi_api/serializers/network/__init__.py +1 -0
- unifi_api/serializers/network/acl.py +46 -0
- unifi_api/serializers/network/ap_groups.py +41 -0
- unifi_api/serializers/network/client_groups.py +53 -0
- unifi_api/serializers/network/clients.py +43 -0
- unifi_api/serializers/network/content_filter.py +43 -0
- unifi_api/serializers/network/devices.py +55 -0
- unifi_api/serializers/network/dns.py +35 -0
- unifi_api/serializers/network/dpi.py +10 -0
- unifi_api/serializers/network/events.py +71 -0
- unifi_api/serializers/network/firewall_rules.py +53 -0
- unifi_api/serializers/network/networks.py +34 -0
- unifi_api/serializers/network/oon.py +51 -0
- unifi_api/serializers/network/port_forwards.py +48 -0
- unifi_api/serializers/network/qos.py +47 -0
- unifi_api/serializers/network/routes.py +36 -0
- unifi_api/serializers/network/sessions.py +13 -0
- unifi_api/serializers/network/snmp.py +39 -0
- unifi_api/serializers/network/stats.py +21 -0
- unifi_api/serializers/network/switch.py +50 -0
- unifi_api/serializers/network/system.py +43 -0
- unifi_api/serializers/network/vouchers.py +36 -0
- unifi_api/serializers/network/vpn.py +33 -0
- unifi_api/serializers/network/wlans.py +33 -0
- unifi_api/serializers/protect/__init__.py +1 -0
- unifi_api/serializers/protect/alarms.py +39 -0
- unifi_api/serializers/protect/cameras.py +48 -0
- unifi_api/serializers/protect/chimes.py +47 -0
- unifi_api/serializers/protect/events.py +84 -0
- unifi_api/serializers/protect/lights.py +31 -0
- unifi_api/serializers/protect/liveviews.py +39 -0
- unifi_api/serializers/protect/recordings.py +38 -0
- unifi_api/serializers/protect/sensors.py +12 -0
- unifi_api/serializers/protect/system.py +15 -0
- unifi_api/server.py +446 -0
- unifi_api/services/__init__.py +1 -0
- unifi_api/services/actions.py +312 -0
- unifi_api/services/audit.py +80 -0
- unifi_api/services/audit_pruner.py +60 -0
- unifi_api/services/capability_cache.py +52 -0
- unifi_api/services/controllers.py +194 -0
- unifi_api/services/diagnostics.py +96 -0
- unifi_api/services/dispatch_overrides.py +98 -0
- unifi_api/services/log_reader.py +66 -0
- unifi_api/services/managers.py +383 -0
- unifi_api/services/manifest.py +168 -0
- unifi_api/services/pagination.py +75 -0
- unifi_api/services/pydantic_models.py +33 -0
- unifi_api/services/resource_routes.py +41 -0
- unifi_api/services/settings.py +82 -0
- unifi_api/services/stream_generator.py +75 -0
- unifi_api/services/streams.py +67 -0
- unifi_api/static/admin/README.md +20 -0
- unifi_api/static/admin/admin.css +23 -0
- unifi_api/static/admin/admin.js +31 -0
- unifi_api/static/admin/htmx-sse.min.js +290 -0
- unifi_api/static/admin/htmx.min.js +1 -0
- unifi_api/static/admin/pico.min.css +4 -0
- unifi_api/templates/admin/_diagnostics_fragment.html +26 -0
- unifi_api/templates/admin/_layout.html +34 -0
- unifi_api/templates/admin/_macros.html +31 -0
- unifi_api/templates/admin/audit/_filters.html +48 -0
- unifi_api/templates/admin/audit/_row.html +8 -0
- unifi_api/templates/admin/audit/_rows.html +14 -0
- unifi_api/templates/admin/audit/list.html +29 -0
- unifi_api/templates/admin/controllers/_form.html +55 -0
- unifi_api/templates/admin/controllers/_probe_result.html +16 -0
- unifi_api/templates/admin/controllers/_row.html +20 -0
- unifi_api/templates/admin/controllers/_table.html +3 -0
- unifi_api/templates/admin/controllers/list.html +33 -0
- unifi_api/templates/admin/dashboard.html +8 -0
- unifi_api/templates/admin/keys/_created_modal.html +13 -0
- unifi_api/templates/admin/keys/_row.html +23 -0
- unifi_api/templates/admin/keys/_table.html +3 -0
- unifi_api/templates/admin/keys/list.html +48 -0
- unifi_api/templates/admin/login.html +51 -0
- unifi_api/templates/admin/settings/_form.html +56 -0
- unifi_api/templates/admin/settings/_prune_result.html +5 -0
- unifi_api/templates/admin/settings/page.html +22 -0
- unifi_api_server-0.1.0.dist-info/METADATA +160 -0
- unifi_api_server-0.1.0.dist-info/RECORD +251 -0
- unifi_api_server-0.1.0.dist-info/WHEEL +4 -0
- 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
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
|
unifi_api/alembic/env.py
ADDED
|
@@ -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
|
unifi_api/auth/cache.py
ADDED
|
@@ -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
|
unifi_api/auth/scopes.py
ADDED
|
@@ -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
|