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