Spaces:
Running
Running
| """ | |
| Per-subscriber HF Space provisioning, with per-subscriber credential isolation. | |
| This exists because `huggingface-spaces-hosting.md:48` requires it in as many words: | |
| "Each subscriber's own Space needs its own credential/vault isolation, consistent with | |
| credentials-and-secrets.md's 'every subscriber's provisioned credential must be | |
| genuinely unique to them' — a per-user Space is exactly the kind of boundary that should | |
| carry per-user credentials, not a shared set." | |
| Design constraints taken directly from the two reference docs, not invented here: | |
| * One shared, namespaced credential scheme — NOT a bespoke store per feature or per user | |
| (`credentials-and-secrets.md:43`). Every per-user secret is keyed by (purpose, user_id) | |
| through one derivation function, so adding a new secret type does not add a new system. | |
| * Genuine per-user uniqueness with no collisions, verified rather than assumed | |
| (`credentials-and-secrets.md:45`). Two subscribers must never be able to alias to, | |
| overwrite, or be confused with each other's credentials. | |
| * Clever, not brute-force (`credentials-and-secrets.md:41`). We do NOT register a new | |
| upstream account with every provider for every subscriber. Each subscriber gets a | |
| derived, individually-attributable token scoped to them, plus their own Space secrets; | |
| upstream provider capacity stays shared, but every call is attributable to exactly one | |
| account, which is what metering and billing actually require. | |
| * The owner is exempt (`huggingface-spaces-hosting.md:51`, `credentials-and-secrets.md:53`). | |
| The hidden owner plan keeps its own existing hosting and is never provisioned a | |
| per-subscriber Space. | |
| Derivation | |
| ---------- | |
| Per-user secrets are HKDF-style derived from one high-entropy root | |
| (`SUBSCRIBER_ROOT_SECRET`) plus the user id and the purpose label. That gives: | |
| - uniqueness per (user, purpose) without storing N×M random values, | |
| - collision-resistance from SHA-256 over a length-prefixed, unambiguous input, | |
| - reproducibility, so a lost Space can be re-provisioned identically, | |
| - and revocability per user via a per-user `version` counter. | |
| Length-prefixing matters: naively concatenating user id and purpose would let | |
| ("ab", "c") and ("a", "bc") derive the same key. The encoder below makes that | |
| impossible, and `verify_no_collisions()` proves it rather than asserting it. | |
| Nothing in this module creates a Space unless `dry_run=False` is passed explicitly — | |
| provisioning a real Space is an outward-facing action and is left to an explicit, | |
| authorised call. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import hmac | |
| import logging | |
| import os | |
| import secrets | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| log = logging.getLogger(__name__) | |
| # The purposes a subscriber's own Space needs its own value for. One namespaced scheme, | |
| # per credentials-and-secrets.md:43 — adding a purpose here is the ONLY thing needed to | |
| # give every subscriber their own isolated value for it. | |
| # | |
| # v22-2: these are the SYSTEM-DERIVED secrets. Every one is a value this system invents | |
| # for itself, so HMAC derivation from a root secret works: the value has no meaning to | |
| # any third party and nobody outside has to agree to it. | |
| DERIVED_SECRET_PURPOSES: tuple[str, ...] = ( | |
| "JARVIS_CLOUD_TOKEN", # the subscriber's own API/WS bearer for their Space | |
| "VAULT_MASTER_PASSWORD", # derives that Space's vault key; never shared | |
| "PBKDF2_SALT", # per-subscriber salt, so no two vaults share a derivation | |
| "WEBHOOK_SIGNING_SECRET", # payment-webhook HMAC, scoped to this subscriber | |
| ) | |
| # v22-2: OWNER-SUPPLIED per-subscriber credentials — a genuinely different provisioning | |
| # pattern the derivation scheme above cannot express, and previously had no place at all. | |
| # | |
| # The distinction that matters: a derived secret is one WE choose, so we can compute it. | |
| # An owner-supplied secret is issued by a THIRD PARTY and the third party must agree it | |
| # is valid. No amount of HMAC produces a Sketchfab key that Sketchfab will honour. These | |
| # can therefore only ever be STORED AND LOOKED UP, never computed — which means | |
| # provisioning has to be able to *block* on one being absent instead of silently | |
| # producing a Space that 401s the first time a subscriber opens the model browser. | |
| # | |
| # Each entry: purpose -> (human description, where the owner obtains it). | |
| OWNER_SUPPLIED_PURPOSES: dict[str, tuple[str, str]] = { | |
| "SKETCHFAB_API_KEY": ( | |
| "That subscriber's own Sketchfab API token, used for model search/download in " | |
| "the AR connector drawer.", | |
| "The owner creates a separate Sketchfab account/token per subscriber at " | |
| "https://sketchfab.com/settings/password and records it against that user id.", | |
| ), | |
| } | |
| # Backwards-compatible alias. Everything that existed before v22-2 (verify_no_collisions, | |
| # rotate_only_this_user, the Part 29 collision proof) means the derived set specifically, | |
| # so this name keeps pointing at exactly what it always pointed at rather than silently | |
| # growing to include credentials that cannot be derived. | |
| SUBSCRIBER_SECRET_PURPOSES: tuple[str, ...] = DERIVED_SECRET_PURPOSES | |
| OWNER_USER_ID = "owner" | |
| def _root_secret() -> bytes: | |
| """The single high-entropy root every per-user secret is derived from. | |
| Sourced from the environment, never hardcoded — per credentials-and-secrets.md:22, | |
| an encryption/derivation root that ships inside the artifact protects nothing. | |
| """ | |
| raw = os.environ.get("SUBSCRIBER_ROOT_SECRET", "").strip() | |
| if not raw: | |
| raise RuntimeError( | |
| "SUBSCRIBER_ROOT_SECRET is not set. Per-subscriber credentials cannot be " | |
| "derived without it. Set it as a Space Secret / environment variable; do " | |
| "not hardcode it, and do not fall back to a default — a predictable root " | |
| "would make every subscriber's credentials predictable." | |
| ) | |
| return raw.encode("utf-8") | |
| def _encode(*parts: str) -> bytes: | |
| """Unambiguous, length-prefixed encoding. | |
| Without this, derive("ab", "c") and derive("a", "bc") would hash identical bytes and | |
| two different subscribers could collide. Each part is prefixed with its byte length. | |
| """ | |
| out = bytearray() | |
| for p in parts: | |
| b = p.encode("utf-8") | |
| out += len(b).to_bytes(4, "big") + b | |
| return bytes(out) | |
| def derive_secret(user_id: str, purpose: str, version: int = 1) -> str: | |
| """Derive this subscriber's value for one purpose. Deterministic and unique.""" | |
| if not user_id or not user_id.strip(): | |
| raise ValueError("user_id is required") | |
| if purpose not in SUBSCRIBER_SECRET_PURPOSES: | |
| raise ValueError(f"unknown purpose {purpose!r}") | |
| mac = hmac.new(_root_secret(), _encode(user_id.strip(), purpose, str(version)), hashlib.sha256) | |
| # urlsafe, no padding — safe in env vars, headers and URLs alike. | |
| import base64 | |
| return base64.urlsafe_b64encode(mac.digest()).decode().rstrip("=") | |
| def subscriber_secrets(user_id: str, version: int = 1) -> dict[str, str]: | |
| """Every isolated secret for one subscriber's own Space.""" | |
| return {p: derive_secret(user_id, p, version) for p in SUBSCRIBER_SECRET_PURPOSES} | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # v22-2 — owner-supplied per-subscriber credentials | |
| # | |
| # These route through the SAME vault the rest of the system uses, namespaced per | |
| # (user, purpose), rather than a new bespoke store. credentials-and-secrets.md:43 is | |
| # explicit about this: "resist the pull to bolt on a bespoke storage/rotation mechanism | |
| # for each new kind of secret — extend the existing vault/credential-management path | |
| # with a clean per-user namespace." A parallel store for one connector is exactly the | |
| # unauditable-mess pattern that rule exists to prevent. | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def owner_supplied_vault_key(user_id: str, purpose: str) -> str: | |
| """The vault key one subscriber's owner-supplied credential is stored under. | |
| Namespaced by both user and purpose so two subscribers' Sketchfab keys can never | |
| alias to each other, per the no-collisions requirement that governs the derived | |
| secrets too. | |
| """ | |
| if purpose not in OWNER_SUPPLIED_PURPOSES: | |
| raise ValueError(f"{purpose!r} is not an owner-supplied purpose") | |
| if not user_id or not user_id.strip(): | |
| raise ValueError("user_id is required") | |
| return f"SUBSCRIBER::{user_id.strip()}::{purpose}" | |
| def set_owner_supplied_secret(user_id: str, purpose: str, value: str) -> None: | |
| """Record the key the owner obtained for this specific subscriber.""" | |
| if not value or not value.strip(): | |
| raise ValueError("refusing to store an empty credential") | |
| from backend.services.usb_vault import set_secret | |
| set_secret(owner_supplied_vault_key(user_id, purpose), value.strip()) | |
| def get_owner_supplied_secret(user_id: str, purpose: str) -> str | None: | |
| from backend.services.usb_vault import get_secret | |
| try: | |
| v = get_secret(owner_supplied_vault_key(user_id, purpose)) | |
| except Exception as exc: | |
| log.warning("vault read failed for %s/%s: %s", user_id, purpose, exc) | |
| return None | |
| return v.strip() if v and v.strip() else None | |
| def missing_owner_supplied(user_id: str) -> list[str]: | |
| """Which owner-supplied credentials this subscriber does NOT yet have. | |
| This is the list provisioning must refuse to proceed past, because none of them can | |
| be generated — only the owner can obtain them. | |
| """ | |
| return [p for p in OWNER_SUPPLIED_PURPOSES if not get_owner_supplied_secret(user_id, p)] | |
| def all_subscriber_credentials(user_id: str, version: int = 1) -> dict[str, Any]: | |
| """The complete credential picture for one subscriber: derived AND owner-supplied. | |
| Deliberately reports the two categories separately, and names what is missing, rather | |
| than merging them into one dict where an absent owner-supplied key would look | |
| identical to a present one. | |
| """ | |
| derived = subscriber_secrets(user_id, version) | |
| supplied = {p: get_owner_supplied_secret(user_id, p) for p in OWNER_SUPPLIED_PURPOSES} | |
| missing = [p for p, v in supplied.items() if not v] | |
| return { | |
| "user_id": user_id, | |
| "derived": derived, | |
| "owner_supplied_present": sorted(p for p, v in supplied.items() if v), | |
| "owner_supplied_missing": missing, | |
| "complete": not missing, | |
| "action_required_by_owner": [ | |
| {"purpose": p, | |
| "what": OWNER_SUPPLIED_PURPOSES[p][0], | |
| "how": OWNER_SUPPLIED_PURPOSES[p][1]} | |
| for p in missing | |
| ], | |
| } | |
| def space_id_for(user_id: str, owner_namespace: str) -> str: | |
| """Stable, collision-free Space id for a subscriber. | |
| The user id is hashed rather than embedded, so a subscriber's account identifier | |
| (which may be an email or another personal identifier) never becomes part of a | |
| publicly visible Space name. | |
| """ | |
| digest = hashlib.sha256(_encode("space-id", user_id.strip())).hexdigest()[:16] | |
| return f"{owner_namespace}/omega-sub-{digest}" | |
| def verify_no_collisions(user_ids: list[str], version: int = 1) -> dict[str, Any]: | |
| """Prove uniqueness rather than assume it (credentials-and-secrets.md:45). | |
| Checks that across the given users: no two share any derived secret, no two share a | |
| Space id, and no single user reuses one value across two different purposes. | |
| """ | |
| seen_secret: dict[str, tuple[str, str]] = {} | |
| seen_space: dict[str, str] = {} | |
| collisions: list[str] = [] | |
| for uid in user_ids: | |
| sid = space_id_for(uid, "acme") | |
| if sid in seen_space and seen_space[sid] != uid: | |
| collisions.append(f"space id collision: {uid} vs {seen_space[sid]}") | |
| seen_space[sid] = uid | |
| for purpose, value in subscriber_secrets(uid, version).items(): | |
| if value in seen_secret: | |
| prev_uid, prev_purpose = seen_secret[value] | |
| collisions.append( | |
| f"secret collision: ({uid},{purpose}) == ({prev_uid},{prev_purpose})" | |
| ) | |
| seen_secret[value] = (uid, purpose) | |
| return { | |
| "users": len(user_ids), | |
| "distinct_secrets": len(seen_secret), | |
| "expected_secrets": len(user_ids) * len(SUBSCRIBER_SECRET_PURPOSES), | |
| "distinct_space_ids": len(seen_space), | |
| "collisions": collisions, | |
| "ok": not collisions | |
| and len(seen_secret) == len(user_ids) * len(SUBSCRIBER_SECRET_PURPOSES) | |
| and len(seen_space) == len(user_ids), | |
| } | |
| def rotate_only_this_user(user_id: str, current_version: int) -> dict[str, str]: | |
| """Rotation writes to exactly one subscriber's entries and nothing else. | |
| credentials-and-secrets.md:45 asks for a real end-to-end rotation trace confirming it | |
| updates the intended user's credential and no other. Bumping the version changes | |
| every value for this user and, by construction, cannot alter any other user's, since | |
| user_id is part of every derivation input. | |
| """ | |
| return subscriber_secrets(user_id, current_version + 1) | |
| class ProvisionResult: | |
| user_id: str | |
| space_id: str | |
| secrets_set: list[str] = field(default_factory=list) | |
| created: bool = False | |
| dry_run: bool = True | |
| skipped_reason: str | None = None | |
| errors: list[str] = field(default_factory=list) | |
| # v22-2: owner-supplied credentials this subscriber is still missing. Non-empty means | |
| # a human step is outstanding — the Space can be created, but the connectors those | |
| # keys serve will not work until the owner supplies them. | |
| owner_supplied_missing: list[str] = field(default_factory=list) | |
| owner_action_required: list[dict[str, str]] = field(default_factory=list) | |
| def provision_subscriber_space( | |
| user_id: str, | |
| *, | |
| owner_namespace: str, | |
| template_space: str, | |
| hf_token: str | None = None, | |
| version: int = 1, | |
| dry_run: bool = True, | |
| ) -> ProvisionResult: | |
| """Create (or re-configure) one subscriber's dedicated free-tier Space. | |
| dry_run=True (the default) performs every step except the outward-facing HF calls, | |
| so the whole path — derivation, naming, the exact secret set — can be exercised and | |
| asserted without creating anything real. | |
| The owner is never provisioned a per-subscriber Space | |
| (huggingface-spaces-hosting.md:51). | |
| """ | |
| space_id = space_id_for(user_id, owner_namespace) | |
| result = ProvisionResult(user_id=user_id, space_id=space_id, dry_run=dry_run) | |
| if user_id.strip() == OWNER_USER_ID: | |
| result.skipped_reason = ( | |
| "owner plan keeps its own dedicated hosting; per-subscriber provisioning " | |
| "does not apply (huggingface-spaces-hosting.md:51)" | |
| ) | |
| return result | |
| values = subscriber_secrets(user_id, version) | |
| result.secrets_set = sorted(values) | |
| # v22-2: report owner-supplied credentials BEFORE the dry-run return, so a dry run — | |
| # which is how this path is normally exercised — surfaces the outstanding human step | |
| # rather than reporting a clean provision that would be incomplete in reality. | |
| credentials = all_subscriber_credentials(user_id, version) | |
| result.owner_supplied_missing = credentials["owner_supplied_missing"] | |
| result.owner_action_required = credentials["action_required_by_owner"] | |
| for purpose in result.owner_supplied_missing: | |
| what, how = OWNER_SUPPLIED_PURPOSES[purpose] | |
| log.warning( | |
| "subscriber %s has no %s. It cannot be derived — %s Until then the features " | |
| "it serves will fail for this subscriber.", user_id, purpose, how, | |
| ) | |
| # Owner-supplied values are injected alongside the derived ones where present, so a | |
| # subscriber who HAS been given a key gets it in their own Space's secrets. | |
| for purpose in OWNER_SUPPLIED_PURPOSES: | |
| supplied = get_owner_supplied_secret(user_id, purpose) | |
| if supplied: | |
| values[purpose] = supplied | |
| result.secrets_set = sorted(values) | |
| if dry_run: | |
| return result | |
| token = hf_token or os.environ.get("HF_API_TOKEN") or os.environ.get("HF_TOKEN") or "" | |
| if not token: | |
| result.errors.append("no HF_API_TOKEN/HF_TOKEN available") | |
| return result | |
| try: | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=token) | |
| try: | |
| api.space_info(space_id) | |
| log.info("space %s already exists; reconfiguring secrets only", space_id) | |
| except Exception: | |
| api.duplicate_space(from_id=template_space, to_id=space_id, private=True) | |
| result.created = True | |
| for purpose, value in values.items(): | |
| api.add_space_secret(repo_id=space_id, key=purpose, value=value) | |
| api.restart_space(repo_id=space_id) | |
| except Exception as exc: # pragma: no cover - network path | |
| result.errors.append(f"{type(exc).__name__}: {exc}") | |
| return result | |
| def new_root_secret() -> str: | |
| """Generate a suitable SUBSCRIBER_ROOT_SECRET (operator runs this once).""" | |
| return secrets.token_urlsafe(48) | |