Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@ uv run python scripts/manage_license.py --key slk_... --show
uv run python scripts/manage_license.py --key slk_... --revoke
uv run python scripts/manage_license.py --id 1 --suspend
uv run python scripts/manage_license.py --id 1 --reactivate
uv run python scripts/manage_license.py --id 1 --enable-sync
uv run python scripts/manage_license.py --id 1 --disable-sync
```

## API

`POST /v1/licenses/check-in` — `Authorization: Bearer slk_<key>`. Always returns HTTP 200 when the service itself is healthy, with `valid: bool` in the body — this is deliberate: it's how a caller tells "the service said no" (revoked/expired/suspended/unknown key — apply immediately) apart from "I couldn't reach it" (a real HTTP error — the caller should apply a grace period instead). See `app/api/licenses.py` for the full contract.

`GET /v1/licenses/entitlements` — `Authorization: Bearer slk_<key>`. Read-only entitlement lookup, deliberately separate from `/check-in`: it doesn't touch `last_seen_at` or write a check-in audit row, so a caller validating on every request (e.g. [Sentinel-Sync-Service](https://github.com/SourceBox-LLC/Sentinel-Sync-Service), checking every push) doesn't pollute that audit trail or fight Command Center's own check-in loop for rate-limit headroom. Returns `sync_enabled` — the cloud data-sync entitlement, a separate opt-in on the same license, independent of Sentinel-AI validity.

`GET /health` — pure liveness. `GET /health/ready` — 503 if a critical dependency (database or disk) is down.

## Tests
Expand Down
52 changes: 51 additions & 1 deletion app/api/licenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from app.core.keys import hash_key
from app.core.limiter import get_client_ip, limiter
from app.models.models import License, LicenseCheckIn
from app.schemas.schemas import CheckInRequest, CheckInResponse
from app.schemas.schemas import CheckInRequest, CheckInResponse, EntitlementsResponse

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -138,6 +138,56 @@ def check_in(
tier=license_row.tier,
status=license_row.status,
monthly_run_cap=license_row.monthly_run_cap,
sync_enabled=license_row.sync_enabled,
renews_at=_iso_z(license_row.renews_at) if license_row.renews_at else None,
server_time=_iso_z(now),
)


@router.get("/entitlements", response_model=EntitlementsResponse)
@limiter.limit("60/minute")
def entitlements(
request: Request,
db: Session = Depends(get_db),
authorization: str | None = Header(default=None),
) -> EntitlementsResponse:
"""Read-only entitlement lookup for a license key.

Deliberately separate from /check-in: this doesn't touch
last_seen_at/last_seen_ip or write a LicenseCheckIn audit row, so
services other than Command Center itself (e.g. Sentinel-Sync-Service,
validating a push request's Bearer key) can call it freely without
polluting the check-in audit trail or fighting Command Center's own
15-minute check-in cadence for rate-limit headroom. Higher limit than
/check-in (60/min vs 20/min) for the same reason — expected callers
include a service validating on every request, not just a periodic
background loop.
"""
raw_key = _extract_raw_key(authorization)
key_hash = hash_key(raw_key)
now = datetime.now(tz=UTC).replace(tzinfo=None)

license_row = db.query(License).filter_by(key_hash=key_hash).first()

if license_row is None:
return EntitlementsResponse(valid=False, reason="not_found", server_time=_iso_z(now))

if license_row.status != "active":
result = license_row.status
elif license_row.renews_at is not None and license_row.renews_at < now:
result = "expired"
else:
result = "ok"

if result != "ok":
return EntitlementsResponse(valid=False, reason=result, server_time=_iso_z(now))

return EntitlementsResponse(
valid=True,
reason=None,
license_key_hash=license_row.key_hash,
tier=license_row.tier,
monthly_run_cap=license_row.monthly_run_cap,
sync_enabled=license_row.sync_enabled,
server_time=_iso_z(now),
)
7 changes: 7 additions & 0 deletions app/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from datetime import UTC, datetime

from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Expand Down Expand Up @@ -53,6 +54,12 @@ class License(Base):
tier = Column(String(40), nullable=False, default="self_host_standard")
monthly_run_cap = Column(Integer, nullable=False, default=500)

# Separate opt-in entitlement, not folded into `tier` — sync is a
# distinct product (Command Center's local-DB-to-cloud-Postgres
# mirror, see the Sentinel-Sync-Service plan) that a self-host
# customer may or may not have bought regardless of their AI tier.
sync_enabled = Column(Boolean, nullable=False, default=False, server_default="0")

# STATUS_ACTIVE | STATUS_SUSPENDED | STATUS_REVOKED (see above)
status = Column(String(20), nullable=False, default=STATUS_ACTIVE, index=True)

Expand Down
17 changes: 17 additions & 0 deletions app/schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,22 @@ class CheckInResponse(BaseModel):
tier: Optional[str] = None
status: Optional[str] = None
monthly_run_cap: Optional[int] = None
sync_enabled: bool = False
renews_at: Optional[str] = None
server_time: str


class EntitlementsResponse(BaseModel):
valid: bool
reason: Optional[str] = None
# Stable, server-derived tenant identifier for a validated key — NOT
# the caller-asserted `install_id` from CheckInRequest, which a
# client can set to anything and so can't be trusted as a tenant
# boundary. key_hash is already unique/immutable per License row,
# so callers needing a stable per-license partition key (e.g.
# Sentinel-Sync-Service scoping synced rows) use this instead.
license_key_hash: Optional[str] = None
tier: Optional[str] = None
monthly_run_cap: Optional[int] = None
sync_enabled: bool = False
server_time: str
13 changes: 11 additions & 2 deletions scripts/manage_license.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
uv run python scripts/manage_license.py --key slk_... --revoke
uv run python scripts/manage_license.py --id 1 --suspend
uv run python scripts/manage_license.py --id 1 --reactivate
uv run python scripts/manage_license.py --id 1 --enable-sync
uv run python scripts/manage_license.py --id 1 --disable-sync
"""

from __future__ import annotations
Expand Down Expand Up @@ -37,7 +39,8 @@ def _find(db, args) -> License | None:
def _print(license_row: License) -> None:
print(
f"id={license_row.id} tier={license_row.tier} status={license_row.status} "
f"cap={license_row.monthly_run_cap}/mo key=...{license_row.key_last4} "
f"cap={license_row.monthly_run_cap}/mo sync_enabled={license_row.sync_enabled} "
f"key=...{license_row.key_last4} "
f"customer={license_row.customer_label or license_row.customer_email or '(none)'} "
f"issued_at={license_row.issued_at} renews_at={license_row.renews_at or 'never'} "
f"last_seen_at={license_row.last_seen_at or 'never'} "
Expand All @@ -56,6 +59,8 @@ def main() -> int:
action.add_argument("--revoke", action="store_true")
action.add_argument("--suspend", action="store_true")
action.add_argument("--reactivate", action="store_true")
action.add_argument("--enable-sync", action="store_true", help="Turn on the cloud data-sync entitlement")
action.add_argument("--disable-sync", action="store_true", help="Turn off the cloud data-sync entitlement")
args = parser.parse_args()

# Safety net for a fresh DB file this script is the first process
Expand All @@ -78,9 +83,13 @@ def main() -> int:
license_row.status = STATUS_SUSPENDED
elif args.reactivate:
license_row.status = STATUS_ACTIVE
elif args.enable_sync:
license_row.sync_enabled = True
elif args.disable_sync:
license_row.sync_enabled = False
# --show or no action: just print current state.

if args.revoke or args.suspend or args.reactivate:
if args.revoke or args.suspend or args.reactivate or args.enable_sync or args.disable_sync:
db.add(license_row)
db.commit()
db.refresh(license_row)
Expand Down
78 changes: 78 additions & 0 deletions tests/test_licenses.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,16 @@ def test_active_license_returns_200_valid_true_with_entitlements(client, db_sess
assert body["tier"] == "self_host_standard"
assert body["status"] == "active"
assert body["monthly_run_cap"] == 500
assert body["sync_enabled"] is False
assert body["renews_at"] is not None


def test_checkin_reflects_sync_enabled(client, db_session):
raw_key, _ = _make_license(db_session, sync_enabled=True)
r = _check_in(client, raw_key)
assert r.json()["sync_enabled"] is True


def test_revoked_license_returns_200_valid_false(client, db_session):
raw_key, _ = _make_license(db_session, status="revoked")
r = _check_in(client, raw_key)
Expand Down Expand Up @@ -255,3 +262,74 @@ def test_check_in_is_rate_limited_per_ip(client, db_session):
assert all(r.status_code == 200 for r in responses[:20])
assert responses[20].status_code == 429
assert "Retry-After" in responses[20].headers


# ── /v1/licenses/entitlements ────────────────────────────────────────


def _entitlements(client, raw_key):
return client.get(
"/v1/licenses/entitlements",
headers={"Authorization": f"Bearer {raw_key}"},
)


def test_entitlements_missing_auth_is_401(client):
r = client.get("/v1/licenses/entitlements")
assert r.status_code == 401


def test_entitlements_unknown_key_returns_200_valid_false(client):
r = _entitlements(client, "slk_" + "0" * 32)
assert r.status_code == 200
assert r.json()["valid"] is False
assert r.json()["reason"] == "not_found"


def test_entitlements_for_active_license(client, db_session):
raw_key, license_row = _make_license(
db_session, tier="self_host_standard", monthly_run_cap=500, sync_enabled=True
)
r = _entitlements(client, raw_key)
assert r.status_code == 200
body = r.json()
assert body["valid"] is True
assert body["license_key_hash"] == license_row.key_hash
assert body["tier"] == "self_host_standard"
assert body["monthly_run_cap"] == 500
assert body["sync_enabled"] is True


def test_entitlements_revoked_license_returns_valid_false(client, db_session):
raw_key, _ = _make_license(db_session, status="revoked")
r = _entitlements(client, raw_key)
body = r.json()
assert body["valid"] is False
assert body["reason"] == "revoked"
assert body["license_key_hash"] is None


def test_entitlements_does_not_write_checkin_log(client, db_session):
# This is the entire reason /entitlements exists as a separate
# endpoint from /check-in — callers validating on every request
# (e.g. Sentinel-Sync-Service) must not pollute the check-in audit
# trail or fight Command Center's own check-in loop for rate-limit
# headroom.
raw_key, license_row = _make_license(db_session)
_entitlements(client, raw_key)
rows = db_session.query(LicenseCheckIn).filter_by(license_id=license_row.id).all()
assert len(rows) == 0


def test_entitlements_does_not_update_last_seen(client, db_session):
raw_key, license_row = _make_license(db_session)
_entitlements(client, raw_key)
db_session.refresh(license_row)
assert license_row.last_seen_at is None


def test_entitlements_is_rate_limited_higher_than_checkin(client, db_session):
raw_key, _ = _make_license(db_session)
responses = [_entitlements(client, raw_key) for _ in range(61)]
assert all(r.status_code == 200 for r in responses[:60])
assert responses[60].status_code == 429