Files
saas_backend/app/models/auth/api_key_model.py
T
2026-08-31 20:04:12 -04:00

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 [])