73 lines
2.5 KiB
Python
73 lines
2.5 KiB
Python
"""A key an integration authenticates with.
|
|||
|
|
|
||
|
|
The model holds the hash and the prefix; the raw key exists once, in the response
|
||
|
|
to creating it, and nowhere afterwards.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
from sqlalchemy import Column, DateTime, ForeignKey, String, func
|
||
|
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||
|
|
from sqlalchemy.orm import relationship
|
||
|
|
|
||
|
|
from app.config.database import Base
|
||
|
|
|
||
|
|
|
||
|
|
def hash_secret(raw: str) -> str:
|
||
|
|
"""One place, so the write and the lookup cannot disagree.
|
||
|
|
|
||
|
|
SHA-256 rather than bcrypt for the same reason invitations use it: this is
|
||
|
|
checked on every API request, and the secret is 256 bits of randomness
|
||
|
|
rather than something a person chose, so there is nothing to slow down.
|
||
|
|
"""
|
||
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
class ApiKey(Base):
|
||
|
|
__tablename__ = "api_keys"
|
||
|
|
|
||
|
|
id = Column(UUID(as_uuid=True), primary_key=True,
|
||
|
|
server_default=func.gen_random_uuid())
|
||
|
|
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
|
||
|
|
nullable=False)
|
||
|
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||
|
|
nullable=False)
|
||
|
|
name = Column(String(120), nullable=False)
|
||
|
|
prefix = Column(String(16), nullable=False)
|
||
|
|
key_hash = Column(String(64), nullable=False)
|
||
|
|
scopes = Column(JSONB, nullable=False, default=list)
|
||
|
|
last_used_at = Column(DateTime(timezone=True))
|
||
|
|
expires_at = Column(DateTime(timezone=True))
|
||
|
|
revoked_at = Column(DateTime(timezone=True))
|
||
|
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||
|
|
|
||
|
|
owner = relationship("User", lazy="raise")
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_expired(self) -> bool:
|
||
|
|
return bool(self.expires_at and self.expires_at <= datetime.now(timezone.utc))
|
||
|
|
|
||
|
|
@property
|
||
|
|
def is_live(self) -> bool:
|
||
|
|
return not (self.revoked_at or self.is_expired)
|
||
|
|
|
||
|
|
@property
|
||
|
|
def state(self) -> str:
|
||
|
|
if self.revoked_at:
|
||
|
|
return "revoked"
|
||
|
|
if self.is_expired:
|
||
|
|
return "expired"
|
||
|
|
return "active"
|
||
|
|
|
||
|
|
def scope_list(self) -> list[str]:
|
||
|
|
"""Empty means "whatever the owner can do" rather than "nothing".
|
||
|
|
|
||
|
|
The alternative — empty meaning no permissions — would make a key with a
|
||
|
|
forgotten scope list silently useless, and the failure would look like a
|
||
|
|
platform bug rather than a configuration one.
|
||
|
|
"""
|
||
|
|
return list(self.scopes or [])
|