Coverage for src / idx_api / models / api_key.py: 81%

27 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 11:09 -0700

1"""API Key model for programmatic authentication.""" 

2 

3from datetime import datetime 

4from typing import TYPE_CHECKING, Optional 

5 

6from sqlalchemy import DateTime, ForeignKey, JSON, String, Text 

7from sqlalchemy.orm import Mapped, mapped_column, relationship 

8 

9from idx_api.models.base import Base, TimestampMixin 

10 

11if TYPE_CHECKING: 

12 from idx_api.models.brokerage import Brokerage 

13 from idx_api.models.user import User 

14 

15 

16class APIKey(Base, TimestampMixin): 

17 """API key for programmatic access.""" 

18 

19 __tablename__ = "api_keys" 

20 

21 id: Mapped[int] = mapped_column(primary_key=True) 

22 

23 # Key storage (NEVER store plaintext keys) 

24 key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) 

25 key_prefix: Mapped[str] = mapped_column( 

26 String(20), nullable=False, index=True 

27 ) # e.g., "idx_live_abc" 

28 

29 # Ownership 

30 user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), index=True) 

31 brokerage_id: Mapped[Optional[int]] = mapped_column(ForeignKey("brokerages.id"), index=True) 

32 

33 # Metadata 

34 name: Mapped[str] = mapped_column(String(200), nullable=False) # Human-friendly name 

35 role: Mapped[str] = mapped_column( 

36 String(20), nullable=False, default="broker" 

37 ) # Role for this key 

38 scopes: Mapped[Optional[dict]] = mapped_column(JSON) # Optional permission scopes 

39 

40 # Lifecycle 

41 expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) 

42 last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) 

43 revoked_at: Mapped[Optional[datetime]] = mapped_column( 

44 DateTime(timezone=True) 

45 ) # Soft delete 

46 

47 # Relationships 

48 user: Mapped[Optional["User"]] = relationship(back_populates="api_keys") 

49 brokerage: Mapped[Optional["Brokerage"]] = relationship(back_populates="api_keys") 

50 

51 @property 

52 def is_active(self) -> bool: 

53 """Check if API key is currently active.""" 

54 if self.revoked_at: 

55 return False 

56 if self.expires_at and datetime.now() > self.expires_at: 

57 return False 

58 return True 

59 

60 def __repr__(self) -> str: 

61 return f"<APIKey(id={self.id}, prefix='{self.key_prefix}', name='{self.name}')>"