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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .openai/hosting.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"project_id": "appgprj_6a6a2f4ec8188191a2523fcf705e52ac"
}
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions dashboard/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
22 changes: 13 additions & 9 deletions docs/SECURITY_ASSESSMENT.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -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.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions requirements-constraints.txt
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/strataone/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

__all__ = ["__version__"]

__version__ = "0.3.0"
__version__ = "0.4.0"
26 changes: 18 additions & 8 deletions src/strataone/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}")

Expand Down Expand Up @@ -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")
Expand All @@ -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"}
Expand All @@ -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)
Expand Down Expand Up @@ -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")

Expand Down
7 changes: 5 additions & 2 deletions src/strataone/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading