feat: access extension — NFC door access via boltcards SUN + Home Assistant
Promoted from the door-portal scratch repo to its own repo for install via the aiolabs catalog. Authenticates a tapped Bolt Card via boltcards /verify, authorizes against per-door grants, and fires the door's local Home Assistant webhook to unlock a Z-Wave lock. Fails closed; logs every attempt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
40564f06ec
22 changed files with 986 additions and 0 deletions
160
crud.py
Normal file
160
crud.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import secrets
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .models import AccessLog, CreateDoor, CreateGrant, Door, Grant
|
||||
|
||||
db = Database("ext_access")
|
||||
|
||||
|
||||
# ── Doors ──────────────────────────────────────────────────────────────────
|
||||
async def create_door(wallet_id: str, data: CreateDoor) -> Door:
|
||||
door_id = urlsafe_short_hash()
|
||||
controller_token = data.controller_token or secrets.token_urlsafe(24)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO access.doors (
|
||||
id, wallet, name, controller_token, ha_webhook_url,
|
||||
boltcards_base_url, unlock_timeout_ms, enabled
|
||||
)
|
||||
VALUES (
|
||||
:id, :wallet, :name, :controller_token, :ha_webhook_url,
|
||||
:boltcards_base_url, :unlock_timeout_ms, :enabled
|
||||
)
|
||||
""",
|
||||
{
|
||||
"id": door_id,
|
||||
"wallet": wallet_id,
|
||||
"name": data.name,
|
||||
"controller_token": controller_token,
|
||||
"ha_webhook_url": data.ha_webhook_url,
|
||||
"boltcards_base_url": data.boltcards_base_url,
|
||||
"unlock_timeout_ms": data.unlock_timeout_ms,
|
||||
"enabled": data.enabled,
|
||||
},
|
||||
)
|
||||
door = await get_door(door_id)
|
||||
assert door, "Newly created door couldn't be retrieved"
|
||||
return door
|
||||
|
||||
|
||||
async def update_door(door: Door) -> Door:
|
||||
await db.update("access.doors", door)
|
||||
return door
|
||||
|
||||
|
||||
async def get_door(door_id: str) -> Door | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM access.doors WHERE id = :id", {"id": door_id}, Door
|
||||
)
|
||||
|
||||
|
||||
async def get_door_by_id_or_name(ident: str) -> Door | None:
|
||||
"""The reader's `doorId` may be the door id or its human name."""
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM access.doors WHERE id = :ident OR name = :ident",
|
||||
{"ident": ident},
|
||||
Door,
|
||||
)
|
||||
|
||||
|
||||
async def get_doors(wallet_ids: list[str]) -> list[Door]:
|
||||
if not wallet_ids:
|
||||
return []
|
||||
q = ",".join(f"'{w}'" for w in wallet_ids)
|
||||
return await db.fetchall(
|
||||
f"SELECT * FROM access.doors WHERE wallet IN ({q}) ORDER BY name", model=Door
|
||||
)
|
||||
|
||||
|
||||
async def delete_door(door_id: str) -> None:
|
||||
await db.execute("DELETE FROM access.doors WHERE id = :id", {"id": door_id})
|
||||
await db.execute(
|
||||
"DELETE FROM access.grants WHERE door_id = :id", {"id": door_id}
|
||||
)
|
||||
|
||||
|
||||
# ── Grants ─────────────────────────────────────────────────────────────────
|
||||
async def create_grant(data: CreateGrant) -> Grant:
|
||||
grant_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO access.grants (id, door_id, external_id, label, enabled, expires_at)
|
||||
VALUES (:id, :door_id, :external_id, :label, :enabled, :expires_at)
|
||||
""",
|
||||
{
|
||||
"id": grant_id,
|
||||
"door_id": data.door_id,
|
||||
"external_id": data.external_id.lower(),
|
||||
"label": data.label,
|
||||
"enabled": data.enabled,
|
||||
"expires_at": data.expires_at,
|
||||
},
|
||||
)
|
||||
grant = await get_grant(grant_id)
|
||||
assert grant, "Newly created grant couldn't be retrieved"
|
||||
return grant
|
||||
|
||||
|
||||
async def get_grant(grant_id: str) -> Grant | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM access.grants WHERE id = :id", {"id": grant_id}, Grant
|
||||
)
|
||||
|
||||
|
||||
async def get_grants(door_ids: list[str]) -> list[Grant]:
|
||||
if not door_ids:
|
||||
return []
|
||||
q = ",".join(f"'{d}'" for d in door_ids)
|
||||
return await db.fetchall(
|
||||
f"SELECT * FROM access.grants WHERE door_id IN ({q}) ORDER BY time DESC",
|
||||
model=Grant,
|
||||
)
|
||||
|
||||
|
||||
async def get_active_grant(door_id: str, external_id: str) -> Grant | None:
|
||||
"""The permission decision: an enabled, unexpired grant for this card+door."""
|
||||
return await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM access.grants
|
||||
WHERE door_id = :door_id AND external_id = :external_id AND enabled = true
|
||||
""",
|
||||
{"door_id": door_id, "external_id": external_id.lower()},
|
||||
Grant,
|
||||
)
|
||||
|
||||
|
||||
async def delete_grant(grant_id: str) -> None:
|
||||
await db.execute("DELETE FROM access.grants WHERE id = :id", {"id": grant_id})
|
||||
|
||||
|
||||
# ── Access log ─────────────────────────────────────────────────────────────
|
||||
async def record_log(
|
||||
door_id: str, external_id: str, decision: str, reason: str, ip: str = ""
|
||||
) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO access.logs (id, door_id, external_id, decision, reason, ip)
|
||||
VALUES (:id, :door_id, :external_id, :decision, :reason, :ip)
|
||||
""",
|
||||
{
|
||||
"id": urlsafe_short_hash(),
|
||||
"door_id": door_id,
|
||||
"external_id": external_id,
|
||||
"decision": decision,
|
||||
"reason": reason,
|
||||
"ip": ip,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def get_logs(door_ids: list[str], limit: int = 200) -> list[AccessLog]:
|
||||
if not door_ids:
|
||||
return []
|
||||
q = ",".join(f"'{d}'" for d in door_ids)
|
||||
return await db.fetchall(
|
||||
f"SELECT * FROM access.logs WHERE door_id IN ({q}) "
|
||||
f"ORDER BY time DESC LIMIT {int(limit)}",
|
||||
model=AccessLog,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue