From 7423a25951504bb756cc8c6f82387d34d8229fdb Mon Sep 17 00:00:00 2001 From: John Goulden Date: Wed, 29 Jul 2026 18:49:22 +0200 Subject: [PATCH 1/2] Harden production control plane v0.4 --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 22 ++++++++ Dockerfile | 9 +++- README.md | 9 ++++ dashboard/app.js | 4 +- docs/SECURITY_ASSESSMENT.md | 22 ++++---- pyproject.toml | 2 +- requirements-constraints.txt | 8 +++ src/strataone/__init__.py | 2 +- src/strataone/api.py | 26 +++++++--- src/strataone/jobs.py | 7 ++- src/strataone/store.py | 85 ++++++++++++++++++++++--------- tests/test_enterprise_controls.py | 80 +++++++++++++++++++++++++++++ 13 files changed, 229 insertions(+), 49 deletions(-) create mode 100644 requirements-constraints.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd54fcf..b5e3697 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: python-version: "3.12" - name: Install package - run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" + run: python -m pip install -c requirements-constraints.txt --upgrade pip setuptools && python -m pip install -c requirements-constraints.txt -e ".[dev]" - name: Run tests run: python -m pytest -q diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f60df..d61275e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The format follows the spirit of Keep a Changelog, and this project currently us ## [Unreleased] +## [0.4.0-preview] - 2026-07-29 + +### Added + +- Production control-plane hardening constraints for repeatable CI and container dependency resolution. +- Startup validation for production auth, PostgreSQL state, Redis queueing, tenant enforcement, and placeholder secrets. +- Regression tests for tenant-scoped discovery, discovery import ownership, and durable redaction of sensitive job, approval, and audit details. + +### Changed + +- Discovery runs are now tenant-owned and scoped across list, execute, delete, and import workflows. +- Discovery imports now create managed sites under the requesting tenant scope. +- Job parameters, approval details, audit details, job events, and discovery results are redacted before durable persistence. +- Inline jobs keep transient credentials in memory for the current process while storing only redacted durable job parameters. +- CI and Docker installs now use `requirements-constraints.txt`. +- The API container now runs as a dedicated non-root `strataone` user. + +### Security + +- Closed a discovery tenant-isolation gap where one tenant could see or act on another tenant's discovery run. +- Reduced durable credential exposure risk for transient BMC credentials and sensitive operational metadata. + ## [0.3.0-preview] - 2026-06-04 ### Added diff --git a/Dockerfile b/Dockerfile index d2c9ef7..c03fcb8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,12 +5,17 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /app -COPY pyproject.toml README.md CHANGELOG.md LICENSE ./ +COPY pyproject.toml README.md CHANGELOG.md LICENSE requirements-constraints.txt ./ COPY src ./src COPY examples ./examples -RUN python -m pip install --no-cache-dir . +RUN python -m pip install --no-cache-dir -c requirements-constraints.txt . +RUN useradd --create-home --shell /usr/sbin/nologin strataone \ + && mkdir -p /app/.strataone \ + && chown -R strataone:strataone /app/.strataone EXPOSE 8080 +USER strataone + CMD ["uvicorn", "strataone.api:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md index af05d74..4d3c576 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,13 @@ Run a worker directly with: strataone worker ``` +Production startup blocks insecure control-plane posture when +`STRATAONE_ENVIRONMENT=production`. Before production use, configure non-default +bootstrap, admin, and PostgreSQL passwords; keep authentication and tenant +enforcement enabled; use PostgreSQL for durable state; and use Redis when queued +execution is enabled. CI and container builds use `requirements-constraints.txt` +to keep dependency resolution repeatable and auditable. + Set `STRATAONE_REQUIRE_OEM_VALIDATION=true` to require OEM lab validation evidence before live Redfish operations are allowed. Validation records are read from `STRATAONE_OEM_VALIDATION_FILE`: ```json @@ -410,8 +417,10 @@ The next implementation milestone is to implement provider-specific Azure Local StrataOne now includes the core controls expected before lab production validation: - Authentication and enforced RBAC are available through bearer tokens and route permissions. +- Tenant-scoped access covers sites, jobs, inventory, approvals, discovery runs, and artifact paths. - CORS is configurable by environment and wildcard origins are rejected when auth is enabled. - BMC credentials can be resolved from env, local secret files, or a Vault-compatible API. +- Durable job, approval, audit, event, and discovery records redact sensitive values before storage. - Redfish virtual media and boot override calls have mock integration coverage. - PostgreSQL and Redis are available as the production state and queue backend. - Live Redfish operations can be blocked unless OEM lab validation evidence is registered. diff --git a/dashboard/app.js b/dashboard/app.js index 5265421..f0b7ef6 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -45,12 +45,12 @@ const state = { const productInfo = { name: "StrataOne", edition: "Enterprise preview", - version: "0.3.0-preview", + version: "0.4.0-preview", developer: "John Goulden", organization: "VirtuArchitect", repository: "https://github.com/VirtuArchitect/StrataOne", license: "MIT", - apiVersion: "0.3.0", + apiVersion: "0.4.0", }; const wizardSteps = ["intent", "hardware", "platform", "network", "review"]; diff --git a/docs/SECURITY_ASSESSMENT.md b/docs/SECURITY_ASSESSMENT.md index 7268efc..4835da6 100644 --- a/docs/SECURITY_ASSESSMENT.md +++ b/docs/SECURITY_ASSESSMENT.md @@ -1,8 +1,8 @@ # Security Assessment Summary -Assessment date: 2026-06-05 +Assessment date: 2026-07-29 -Project version: StrataOne 0.3.0 +Project version: StrataOne 0.4.0 Assessment type: Local defensive repository assessment and targeted API security validation. @@ -28,16 +28,16 @@ Commands executed during validation: ```powershell python -m pytest -q python -m pip_audit --skip-editable -python -m bandit -r src --severity-level high +python -m bandit -r src --severity-level medium docker compose config --quiet git diff --check ``` Latest local results: -- Unit and regression tests: `72 passed` +- Unit and regression tests: `78 passed` - Dependency audit: no known vulnerabilities found -- Static security scan: no high-severity Bandit findings +- Static security scan: no medium-or-high-severity Bandit findings - Docker Compose configuration: valid - Git diff whitespace check: clean @@ -53,8 +53,11 @@ The assessment identified and remediated the following security issues: | SSRF | ISO validation and notification webhook tests could call arbitrary local/private targets. | Outbound URL validation now blocks localhost, private, reserved, link-local, multicast, and unresolved targets. | | Token handling | Dashboard sessions used persistent local storage and WebSocket query tokens. | Dashboard tokens now use session storage, and authenticated live event streaming avoids bearer tokens in URLs. | | Audit secrecy | Provider configuration audit events could include sensitive values. | Provider configuration audit payloads now redact sensitive key names. | +| Discovery tenancy | Discovery runs were globally visible and import wrote sites outside the requester tenant scope. | Discovery runs are tenant-owned and scoped for list, execute, delete, and import; imported sites inherit requester tenant scope. | +| Durable secret exposure | Transient job credentials could be persisted in job params, approval details, and operational event data. | Durable job params, approvals, audit details, job events, and discovery results are redacted before storage; inline execution keeps one-time params in memory only. | | Dependency hygiene | Dev dependency declaration used `httpx2`; local audit flagged vulnerable pytest. | Dev dependencies now use `httpx`, pytest is pinned to a fixed major line, and audit tools are included. | -| CI visibility | CI did not run dependency or static security gates. | CI now runs tests, `pip-audit`, Bandit high-severity scanning, Docker build, and Compose validation. | +| Dependency repeatability | CI and container builds resolved dependencies without a shared constraints file. | CI and Docker installs now use `requirements-constraints.txt` with audited minimum versions. | +| CI visibility | CI did not run dependency or static security gates. | CI now runs tests, `pip-audit`, Bandit medium-or-higher severity scanning, Docker build, and Compose validation. | ## Current Security Controls @@ -63,14 +66,16 @@ Implemented controls include: - Bearer-token and local password session authentication. - RBAC route enforcement. - Server-side session revocation. -- Tenant-scoped site, job, inventory, approval, and artifact access. +- Tenant-scoped site, job, inventory, approval, discovery, and artifact access. - Configurable CORS with wildcard rejection when auth is enabled. - Security headers and CSP through API/proxy/dashboard configuration. - Rate limiting middleware. - Approval gates for protected live-impact actions. - Secret reference model with environment, file, and Vault-compatible providers. - Production startup guard for known placeholder credentials. +- Production startup guard for auth, tenant enforcement, PostgreSQL state, and Redis queue posture. - Dependency audit and static security gates in CI. +- Durable redaction for sensitive job, approval, audit, event, and discovery payloads. ## Limitations @@ -89,8 +94,7 @@ Before production use, StrataOne should still undergo environment-specific valid - Add third-party penetration testing before production customer deployment. - Add live lab validation evidence for each hardware and hypervisor provider. - Add SAST/DAST reporting artifacts to CI. -- Add dependency lockfiles or SBOM generation. +- Generate SBOMs and signed release attestations. - Add container image vulnerability scanning. - Add OIDC/SAML enterprise identity provider integration. - Add centralized audit export to SIEM or log analytics. - diff --git a/pyproject.toml b/pyproject.toml index 42cefc5..c1e191e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "strataone" -version = "0.3.0" +version = "0.4.0" description = "Vendor-agnostic, hypervisor-agnostic zero-touch orchestration for distributed infrastructure." readme = "README.md" requires-python = ">=3.11" diff --git a/requirements-constraints.txt b/requirements-constraints.txt new file mode 100644 index 0000000..ac3d726 --- /dev/null +++ b/requirements-constraints.txt @@ -0,0 +1,8 @@ +# Production Control Plane Hardening v0.4 +# +# Keep CI, Docker, and operator installs on audited minimums while allowing +# compatible patch releases above these floors. +starlette>=1.3.1 +msgpack>=1.2.1 +setuptools>=83.0.0 +pillow>=12.3.0 diff --git a/src/strataone/__init__.py b/src/strataone/__init__.py index 1b5a1ca..4f8b8a1 100644 --- a/src/strataone/__init__.py +++ b/src/strataone/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/strataone/api.py b/src/strataone/api.py index 0a1fe48..1c09994 100644 --- a/src/strataone/api.py +++ b/src/strataone/api.py @@ -337,6 +337,16 @@ def validate_secure_runtime_defaults() -> None: for name, blocked in insecure_values.items() if os.getenv(name, "") in blocked ] + if not auth_enabled(): + failures.append("STRATAONE_AUTH_ENABLED") + if os.getenv("STRATAONE_STATE_BACKEND", "sqlite").lower() != "postgres": + failures.append("STRATAONE_STATE_BACKEND") + if os.getenv("STRATAONE_EXECUTION_MODE", "inline").lower() == "queued" and queue_backend() != "redis": + failures.append("STRATAONE_QUEUE_BACKEND") + if not os.getenv("STRATAONE_TENANT_ENFORCEMENT", "true").lower() in {"1", "true", "yes", "on"}: + failures.append("STRATAONE_TENANT_ENFORCEMENT") + if "*" in cors_origins(): + failures.append("STRATAONE_CORS_ORIGINS") if failures: raise RuntimeError(f"production startup blocked by insecure default setting(s): {', '.join(failures)}") @@ -569,8 +579,8 @@ def test_secret_ref(secret_name: str, context: AuthContext = manage_settings) -> @app.get("/discovery") -def list_discovery(_: Any = read_sites) -> dict[str, Any]: - return {"runs": [item.model_dump(mode="json") for item in store.list_discovery_runs()]} +def list_discovery(context: AuthContext = read_sites) -> dict[str, Any]: + return {"runs": [item.model_dump(mode="json") for item in store.list_discovery_runs(_tenant_scope(context))]} @app.post("/discovery") @@ -592,14 +602,14 @@ def create_discovery(payload: DiscoveryPayload, context: AuthContext = Depends(r "candidates": [{"bmc_ip": address, "status": "pending-scan"} for address in candidates], "next_actions": ["Run Redfish reachability scan", "Classify reachable systems", "Import selected nodes into a deployment"], } - record = store.create_discovery_run(payload.name.strip() or "BMC discovery", str(network), provider.name, result) + record = store.create_discovery_run(payload.name.strip() or "BMC discovery", str(network), provider.name, result, tenant_id=_tenant_scope(context)) store.add_audit(context.username, "discovery.plan", f"discovery:{record.id}", {"cidr": str(network), "provider": provider.name}) return record.model_dump(mode="json") @app.post("/discovery/{run_id}/execute") def execute_discovery(run_id: str, context: AuthContext = Depends(require_permission("run-inventory", store))) -> dict[str, Any]: - run = store.get_discovery_run(run_id) + run = store.get_discovery_run(run_id, _tenant_scope(context)) if run is None: raise HTTPException(status_code=404, detail="discovery run not found") live = os.getenv("STRATAONE_ENABLE_LIVE_REDFISH", "false").lower() in {"1", "true", "yes", "on"} @@ -621,21 +631,21 @@ def execute_discovery(run_id: str, context: AuthContext = Depends(require_permis "scanned_count": len(scanned), } status = "completed" if live else "ready-for-live-scan" - updated = store.update_discovery_run(run_id, status, result) + updated = store.update_discovery_run(run_id, status, result, _tenant_scope(context)) store.add_audit(context.username, "discovery.execute", f"discovery:{run_id}", {"status": status, "live": live}) return updated.model_dump(mode="json") if updated else run.model_dump(mode="json") @app.delete("/discovery/{run_id}") def delete_discovery(run_id: str, context: AuthContext = Depends(require_permission("run-inventory", store))) -> dict[str, bool]: - deleted = store.delete_discovery_run(run_id) + deleted = store.delete_discovery_run(run_id, _tenant_scope(context)) store.add_audit(context.username, "discovery.delete", f"discovery:{run_id}", {"deleted": deleted}) return {"deleted": deleted} @app.post("/discovery/{run_id}/import") def import_discovery(run_id: str, payload: DiscoveryImportPayload, context: AuthContext = Depends(require_permission("create-sites", store))) -> dict[str, Any]: - run = store.get_discovery_run(run_id) + run = store.get_discovery_run(run_id, _tenant_scope(context)) if run is None: raise HTTPException(status_code=404, detail="discovery run not found") selected = set(payload.selected_bmc_ips) @@ -677,7 +687,7 @@ def import_discovery(run_id: str, payload: DiscoveryImportPayload, context: Auth "workloads": {}, } ) - site = store.upsert_site(spec) + site = store.upsert_site(spec, tenant_id=_tenant_scope(context)) store.add_audit(context.username, "discovery.import", f"site:{site.name}", {"discovery_id": run_id, "nodes": len(nodes)}) return site.model_dump(mode="json") diff --git a/src/strataone/jobs.py b/src/strataone/jobs.py index 933bb40..beccb46 100644 --- a/src/strataone/jobs.py +++ b/src/strataone/jobs.py @@ -18,12 +18,15 @@ class JobRunner: def __init__(self, store: StrataStore, max_workers: int = 4) -> None: self.store = store self.executor = ThreadPoolExecutor(max_workers=max_workers) + self._volatile_params: dict[str, dict[str, Any]] = {} def submit(self, site_name: str, action: str, params: dict[str, Any] | None = None) -> str: - job = self.store.create_job(site_name, action, params or {}) + raw_params = params or {} + job = self.store.create_job(site_name, action, raw_params) execution_mode = os.getenv("STRATAONE_EXECUTION_MODE", "inline").lower() queue = get_job_queue() if execution_mode == "inline": + self._volatile_params[job.id] = raw_params self.executor.submit(self._run, job.id) elif queue is not None: queue.enqueue(job.id) @@ -55,7 +58,7 @@ def _run(self, job_id: str, already_started: bool = False) -> None: raise ValueError(f"site {job.site_name} not found") spec = spec_from_record(site) self.store.add_job_event(job_id, "info", f"Executing {job.action}", {"site": spec.site.name}) - params = {**job.params, "_job_id": job_id} + params = {**job.params, **self._volatile_params.pop(job_id, {}), "_job_id": job_id} result = self._execute(job.action, spec, params) self.store.finish_job(job_id, result) except Exception as exc: diff --git a/src/strataone/store.py b/src/strataone/store.py index 2e1495a..e08f79d 100644 --- a/src/strataone/store.py +++ b/src/strataone/store.py @@ -152,6 +152,7 @@ class SecretRefRecord(BaseModel): class DiscoveryRunRecord(BaseModel): id: str + tenant_id: str = "default" name: str cidr: str provider: str @@ -423,6 +424,7 @@ def _init(self) -> None: """ CREATE TABLE IF NOT EXISTS discovery_runs ( id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL DEFAULT 'default', name TEXT NOT NULL, cidr TEXT NOT NULL, provider TEXT NOT NULL, @@ -465,6 +467,7 @@ def _init(self) -> None: self._ensure_column(conn, "approvals", "required_approvals", "INTEGER NOT NULL DEFAULT 1") self._ensure_column(conn, "approvals", "approver_roles_json", "TEXT NOT NULL DEFAULT '[]'") self._ensure_column(conn, "approvals", "expires_at", "TEXT") + self._ensure_column(conn, "discovery_runs", "tenant_id", "TEXT NOT NULL DEFAULT 'default'") if self.backend == "postgres": conn.execute("ALTER TABLE sites ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default'") conn.execute("ALTER TABLE jobs ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default'") @@ -475,6 +478,7 @@ def _init(self) -> None: conn.execute("ALTER TABLE approvals ADD COLUMN IF NOT EXISTS required_approvals INTEGER NOT NULL DEFAULT 1") conn.execute("ALTER TABLE approvals ADD COLUMN IF NOT EXISTS approver_roles_json TEXT NOT NULL DEFAULT '[]'") conn.execute("ALTER TABLE approvals ADD COLUMN IF NOT EXISTS expires_at TEXT") + conn.execute("ALTER TABLE discovery_runs ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'default'") self.apply_migrations() self.seed_access_defaults() @@ -487,6 +491,7 @@ def apply_migrations(self) -> None: ("005_approval_policy", "Configurable approval policy rules"), ("006_approval_votes_provider_validation", "Approval votes and provider validation evidence"), ("007_tenant_provider_validation_runs", "Tenant metadata and live provider validation run history"), + ("008_control_plane_hardening", "Tenant-scoped discovery and persisted secret redaction"), ] applied = {item.version for item in self.list_migrations()} with self._connect() as conn: @@ -567,7 +572,7 @@ def create_job(self, site_name: str, action: str, params: dict[str, Any] | None INSERT INTO jobs (id, tenant_id, site_name, action, status, params_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (job_id, tenant_id, site_name, action, "queued", json.dumps(params or {}), now), + (job_id, tenant_id, site_name, action, "queued", json.dumps(redact_sensitive(params or {})), now), ) return self.get_job(job_id) # type: ignore[return-value] @@ -901,9 +906,9 @@ def add_audit(self, actor: str, action: str, resource: str, detail: dict[str, An with self._connect() as conn: conn.execute( "INSERT INTO audit_log (id, actor, action, resource, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?)", - (audit_id, actor, action, resource, json.dumps(detail or {}), now), + (audit_id, actor, action, resource, json.dumps(redact_sensitive(detail or {})), now), ) - return AuditRecord(id=audit_id, actor=actor, action=action, resource=resource, detail=detail or {}, created_at=now) + return AuditRecord(id=audit_id, actor=actor, action=action, resource=resource, detail=redact_sensitive(detail or {}), created_at=now) def list_audit(self, limit: int = 100) -> list[AuditRecord]: with self._connect() as conn: @@ -936,7 +941,7 @@ def create_approval( action, "pending", requested_by, - json.dumps(detail or {}), + json.dumps(redact_sensitive(detail or {})), "[]", max(1, required_approvals), json.dumps(approver_roles or []), @@ -1059,9 +1064,9 @@ def add_job_event(self, job_id: str, level: str, message: str, detail: dict[str, with self._connect() as conn: conn.execute( "INSERT INTO job_events (id, job_id, level, message, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?)", - (event_id, job_id, level, message, json.dumps(detail or {}), now), + (event_id, job_id, level, message, json.dumps(redact_sensitive(detail or {})), now), ) - return JobEventRecord(id=event_id, job_id=job_id, level=level, message=message, detail=detail or {}, created_at=now) + return JobEventRecord(id=event_id, job_id=job_id, level=level, message=message, detail=redact_sensitive(detail or {}), created_at=now) def list_job_events(self, job_id: str) -> list[JobEventRecord]: with self._connect() as conn: @@ -1190,40 +1195,55 @@ def delete_secret_ref(self, name: str) -> bool: result = conn.execute("DELETE FROM secret_refs WHERE name = ?", (name,)) return result.rowcount > 0 - def create_discovery_run(self, name: str, cidr: str, provider: str, result: dict[str, Any], status: str = "planned") -> DiscoveryRunRecord: + def create_discovery_run(self, name: str, cidr: str, provider: str, result: dict[str, Any], status: str = "planned", tenant_id: str = "default") -> DiscoveryRunRecord: run_id = str(uuid.uuid4()) now = _now() with self._connect() as conn: conn.execute( """ - INSERT INTO discovery_runs (id, name, cidr, provider, status, result_json, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO discovery_runs (id, tenant_id, name, cidr, provider, status, result_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (run_id, name, cidr, provider, status, json.dumps(result), now, now), + (run_id, tenant_id, name, cidr, provider, status, json.dumps(redact_sensitive(result)), now, now), ) - return self.get_discovery_run(run_id) # type: ignore[return-value] + return self.get_discovery_run(run_id, tenant_id) # type: ignore[return-value] - def update_discovery_run(self, run_id: str, status: str, result: dict[str, Any]) -> DiscoveryRunRecord | None: + def update_discovery_run(self, run_id: str, status: str, result: dict[str, Any], tenant_id: str | None = None) -> DiscoveryRunRecord | None: with self._connect() as conn: - conn.execute( - "UPDATE discovery_runs SET status = ?, result_json = ?, updated_at = ? WHERE id = ?", - (status, json.dumps(result), _now(), run_id), - ) - return self.get_discovery_run(run_id) + if tenant_id and tenant_id != "*": + conn.execute( + "UPDATE discovery_runs SET status = ?, result_json = ?, updated_at = ? WHERE id = ? AND tenant_id = ?", + (status, json.dumps(redact_sensitive(result)), _now(), run_id, tenant_id), + ) + else: + conn.execute( + "UPDATE discovery_runs SET status = ?, result_json = ?, updated_at = ? WHERE id = ?", + (status, json.dumps(redact_sensitive(result)), _now(), run_id), + ) + return self.get_discovery_run(run_id, tenant_id) - def get_discovery_run(self, run_id: str) -> DiscoveryRunRecord | None: + def get_discovery_run(self, run_id: str, tenant_id: str | None = None) -> DiscoveryRunRecord | None: with self._connect() as conn: - row = conn.execute("SELECT * FROM discovery_runs WHERE id = ?", (run_id,)).fetchone() + if tenant_id and tenant_id != "*": + row = conn.execute("SELECT * FROM discovery_runs WHERE id = ? AND tenant_id = ?", (run_id, tenant_id)).fetchone() + else: + row = conn.execute("SELECT * FROM discovery_runs WHERE id = ?", (run_id,)).fetchone() return self._discovery_run_from_row(row) if row else None - def list_discovery_runs(self) -> list[DiscoveryRunRecord]: + def list_discovery_runs(self, tenant_id: str | None = None) -> list[DiscoveryRunRecord]: with self._connect() as conn: - rows = conn.execute("SELECT * FROM discovery_runs ORDER BY created_at DESC").fetchall() + if tenant_id and tenant_id != "*": + rows = conn.execute("SELECT * FROM discovery_runs WHERE tenant_id = ? ORDER BY created_at DESC", (tenant_id,)).fetchall() + else: + rows = conn.execute("SELECT * FROM discovery_runs ORDER BY created_at DESC").fetchall() return [self._discovery_run_from_row(row) for row in rows] - def delete_discovery_run(self, run_id: str) -> bool: + def delete_discovery_run(self, run_id: str, tenant_id: str | None = None) -> bool: with self._connect() as conn: - result = conn.execute("DELETE FROM discovery_runs WHERE id = ?", (run_id,)) + if tenant_id and tenant_id != "*": + result = conn.execute("DELETE FROM discovery_runs WHERE id = ? AND tenant_id = ?", (run_id, tenant_id)) + else: + result = conn.execute("DELETE FROM discovery_runs WHERE id = ?", (run_id,)) return result.rowcount > 0 def upsert_iso(self, name: str, uri: str, checksum: str | None = None, checksum_algorithm: str = "sha256", status: str = "registered") -> IsoRecord: @@ -1425,6 +1445,7 @@ def _secret_ref_from_row(self, row) -> SecretRefRecord: def _discovery_run_from_row(self, row) -> DiscoveryRunRecord: return DiscoveryRunRecord( id=row["id"], + tenant_id=row["tenant_id"] if "tenant_id" in row.keys() else "default", name=row["name"], cidr=row["cidr"], provider=row["provider"], @@ -1456,3 +1477,21 @@ def _now() -> str: def _postgres_sql(sql: str) -> str: return sql.replace("?", "%s") + + +SENSITIVE_FIELD_MARKERS = ("password", "secret", "token", "key") + + +def redact_sensitive(value: Any) -> Any: + if isinstance(value, dict): + redacted = {} + for key, item in value.items(): + lowered = str(key).lower() + if any(marker in lowered for marker in SENSITIVE_FIELD_MARKERS): + redacted[key] = "***" + else: + redacted[key] = redact_sensitive(item) + return redacted + if isinstance(value, list): + return [redact_sensitive(item) for item in value] + return value diff --git a/tests/test_enterprise_controls.py b/tests/test_enterprise_controls.py index 6e40b49..75d1288 100644 --- a/tests/test_enterprise_controls.py +++ b/tests/test_enterprise_controls.py @@ -77,6 +77,44 @@ def test_tenant_scoped_token_cannot_read_other_tenant_job(monkeypatch) -> None: assert events_from_b.status_code == 404 +def test_tenant_scoped_token_only_lists_and_imports_own_discovery(monkeypatch) -> None: + monkeypatch.setenv("STRATAONE_AUTH_ENABLED", "true") + monkeypatch.setenv("STRATAONE_BOOTSTRAP_TOKEN", "bootstrap") + monkeypatch.setenv("STRATAONE_API_TOKENS", "tenant-a=tenant.a:Deployment Admin,Operator,Viewer@tenant-a;tenant-b=tenant.b:Deployment Admin,Operator,Viewer@tenant-b") + client = TestClient(app) + + created = client.post( + "/discovery", + json={"name": "Tenant A scan", "cidr": "10.20.30.0/30", "provider": "generic-redfish"}, + headers={"Authorization": "Bearer tenant-a"}, + ) + run_id = created.json()["id"] + list_a = client.get("/discovery", headers={"Authorization": "Bearer tenant-a"}) + list_b = client.get("/discovery", headers={"Authorization": "Bearer tenant-b"}) + execute_from_b = client.post(f"/discovery/{run_id}/execute", headers={"Authorization": "Bearer tenant-b"}) + import_from_b = client.post( + f"/discovery/{run_id}/import", + json={"site_name": "tenant-b-imported", "location": "lab", "selected_bmc_ips": ["10.20.30.1"]}, + headers={"Authorization": "Bearer tenant-b"}, + ) + import_from_a = client.post( + f"/discovery/{run_id}/import", + json={"site_name": "tenant-a-imported-discovery", "location": "lab", "selected_bmc_ips": ["10.20.30.1"]}, + headers={"Authorization": "Bearer tenant-a"}, + ) + get_imported_from_b = client.get("/sites/tenant-a-imported-discovery", headers={"Authorization": "Bearer tenant-b"}) + + assert created.status_code == 200 + assert created.json()["tenant_id"] == "tenant-a" + assert any(run["id"] == run_id for run in list_a.json()["runs"]) + assert all(run["id"] != run_id for run in list_b.json()["runs"]) + assert execute_from_b.status_code == 404 + assert import_from_b.status_code == 404 + assert import_from_a.status_code == 200 + assert import_from_a.json()["tenant_id"] == "tenant-a" + assert get_imported_from_b.status_code == 404 + + def test_site_name_rejects_path_traversal() -> None: client = TestClient(app) site = client.get("/sites/example").json() @@ -128,6 +166,11 @@ def test_production_startup_rejects_placeholder_secrets(monkeypatch) -> None: monkeypatch.setenv("STRATAONE_BOOTSTRAP_TOKEN", "change-this-token") monkeypatch.setenv("STRATAONE_ADMIN_PASSWORD", "change-this-password") monkeypatch.setenv("POSTGRES_PASSWORD", "strataone") + monkeypatch.setenv("STRATAONE_AUTH_ENABLED", "false") + monkeypatch.setenv("STRATAONE_STATE_BACKEND", "sqlite") + monkeypatch.setenv("STRATAONE_EXECUTION_MODE", "queued") + monkeypatch.setenv("STRATAONE_QUEUE_BACKEND", "memory") + monkeypatch.setenv("STRATAONE_TENANT_ENFORCEMENT", "false") try: validate_secure_runtime_defaults() @@ -139,6 +182,43 @@ def test_production_startup_rejects_placeholder_secrets(monkeypatch) -> None: assert "STRATAONE_BOOTSTRAP_TOKEN" in message assert "STRATAONE_ADMIN_PASSWORD" in message assert "POSTGRES_PASSWORD" in message + assert "STRATAONE_AUTH_ENABLED" in message + assert "STRATAONE_STATE_BACKEND" in message + assert "STRATAONE_QUEUE_BACKEND" in message + assert "STRATAONE_TENANT_ENFORCEMENT" in message + + +def test_durable_job_approval_and_audit_details_redact_secrets(monkeypatch) -> None: + monkeypatch.setenv("STRATAONE_REQUIRE_APPROVALS", "true") + client = TestClient(app) + site = client.get("/sites/example").json() + site["site"]["name"] = "redaction-site" + client.post("/sites", json={"site": site}) + + job = client.post( + "/sites/redaction-site/jobs/validate", + json={"username": "admin", "password": "super-secret-password"}, + ).json() + stored_job = client.get(f"/jobs/{job['job_id']}").json() + approval = client.post( + "/sites/redaction-site/jobs/mount-iso", + json={ + "username": "admin", + "password": "approval-secret-password", + "iso_url": "https://repo.example.com/azure-local.iso", + }, + ).json() + approvals = client.get("/approvals").json()["approvals"] + client.post("/providers/generic-redfish/config", json={"config": {"password": "provider-secret", "credential_ref": "branch-bmc"}}) + audit = client.get("/audit?limit=10").json()["audit"] + + approval_record = next(item for item in approvals if item["id"] == approval["approval_id"]) + provider_audit = next(item for item in audit if item["action"] == "provider.config.upsert") + assert stored_job["params"]["password"] == "***" + assert stored_job["params"]["username"] == "admin" + assert approval_record["detail"]["params"]["password"] == "***" + assert provider_audit["detail"]["password"] == "***" + assert provider_audit["detail"]["credential_ref"] == "***" def test_provider_validation_harness_records_contract_run() -> None: From 6b189a9a2b21bb4a4130f866a51d48d8b279ee12 Mon Sep 17 00:00:00 2001 From: John Goulden Date: Wed, 29 Jul 2026 18:50:54 +0200 Subject: [PATCH 2/2] Add Sites console demo hosting --- .openai/hosting.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .openai/hosting.json diff --git a/.openai/hosting.json b/.openai/hosting.json new file mode 100644 index 0000000..b18c92f --- /dev/null +++ b/.openai/hosting.json @@ -0,0 +1,3 @@ +{ + "project_id": "appgprj_6a6a2f4ec8188191a2523fcf705e52ac" +}