diff --git a/README.md b/README.md index 910746b..e90aa42 100644 --- a/README.md +++ b/README.md @@ -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_`. 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_`. 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 diff --git a/app/api/licenses.py b/app/api/licenses.py index ea41dd9..b884c72 100644 --- a/app/api/licenses.py +++ b/app/api/licenses.py @@ -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__) @@ -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), + ) diff --git a/app/models/models.py b/app/models/models.py index 0f33e55..a491e81 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -14,6 +14,7 @@ from datetime import UTC, datetime from sqlalchemy import ( + Boolean, Column, DateTime, ForeignKey, @@ -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) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 13bfe8b..47adb7a 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -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 diff --git a/scripts/manage_license.py b/scripts/manage_license.py index 6c2301b..b2ab235 100644 --- a/scripts/manage_license.py +++ b/scripts/manage_license.py @@ -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 @@ -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'} " @@ -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 @@ -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) diff --git a/tests/test_licenses.py b/tests/test_licenses.py index 90d3617..95447fd 100644 --- a/tests/test_licenses.py +++ b/tests/test_licenses.py @@ -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) @@ -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