diff --git a/README.md b/README.md index ed5a8a8..5971f9c 100644 --- a/README.md +++ b/README.md @@ -109,3 +109,31 @@ The image includes the built Vue UI and serves it from FastAPI. The container does not run migrations automatically; run `alembic upgrade head` explicitly during deploy. + +## Operations + +Required production settings are documented in `.env.example`. In production, +the app fails fast if default secrets are still configured, if SQLite is used, +or if public auth/MCP URLs do not use HTTPS. + +Operational endpoints: + +```text +GET /health +GET /ready +``` + +`/health` only confirms that the process is alive. `/ready` checks database +connectivity and should be used for readiness checks. + +Run database migrations before each deploy: + +```bash +alembic upgrade head +``` + +The session cookie is `HttpOnly`, `SameSite=Lax`, and is marked `Secure` when +`GNEXUS_CREDS_ENV=production`. + +Secrets and old versions are hard-deleted when a secret is deleted. Audit events +are retained and intentionally do not store decrypted secret values. diff --git a/gnexus_creds/api.py b/gnexus_creds/api.py index 1bab470..aa28308 100644 --- a/gnexus_creds/api.py +++ b/gnexus_creds/api.py @@ -32,6 +32,7 @@ create_api_token, create_secret, delete_secret, + get_version, list_secrets, list_versions, reveal_secret, @@ -187,6 +188,16 @@ return list_versions(db, actor, secret_id) +@router.get("/secrets/{secret_id}/versions/{version_id}", response_model=SecretVersionRead) +async def versions_get( + secret_id: UUID, + version_id: UUID, + db: Session = Depends(get_db), + actor: Actor = Depends(actor_from_request), +) -> SecretVersionRead: + return get_version(db, actor, secret_id, version_id) + + @router.post("/secrets/{secret_id}/versions/{version_id}/reveal", response_model=SecretReveal) async def versions_reveal( secret_id: UUID, diff --git a/gnexus_creds/services.py b/gnexus_creds/services.py index ca2e6de..ddc38fe 100644 --- a/gnexus_creds/services.py +++ b/gnexus_creds/services.py @@ -522,6 +522,22 @@ ] +def get_version( + db: Session, actor: Actor, secret_id: uuid.UUID, version_id: uuid.UUID +) -> SecretVersionRead: + actor.require(Scope.read) + secret = _load_secret(db, actor.user.id, secret_id) + version = next((item for item in secret.versions if item.id == version_id), None) + if version is None: + raise AppError("version_not_found", "Secret version not found.", status_code=404) + return SecretVersionRead( + id=version.id, + version_number=version.version_number, + created_at=version.created_at, + fields=_public_fields(version.fields, reveal=False), + ) + + def create_api_token(db: Session, actor: Actor, payload: ApiTokenCreate): actor.require(Scope.admin) check_actor_rate_limit(db, actor, "api_token_create") diff --git a/tests/test_api.py b/tests/test_api.py index 98c4059..6547822 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -93,6 +93,42 @@ @pytest.mark.anyio +async def test_rest_get_version_masks_encrypted_values(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + create_response = await client.post( + "/api/v1/secrets", + json={ + "title": "Versioned", + "fields": [ + {"name": "username", "value": "version-user", "encrypted": False}, + {"name": "password", "value": "first-pass", "encrypted": True, "masked": True}, + ], + }, + ) + secret_id = create_response.json()["id"] + + update_response = await client.patch( + f"/api/v1/secrets/{secret_id}", + json={ + "fields": [ + {"name": "username", "value": "version-user", "encrypted": False}, + {"name": "password", "value": "second-pass", "encrypted": True, "masked": True}, + ] + }, + ) + assert update_response.status_code == 200 + + versions_response = await client.get(f"/api/v1/secrets/{secret_id}/versions") + version_id = versions_response.json()[0]["id"] + version_response = await client.get(f"/api/v1/secrets/{secret_id}/versions/{version_id}") + + assert version_response.status_code == 200 + fields = {field["name"]: field["value"] for field in version_response.json()["fields"]} + assert fields["username"] == "version-user" + assert fields["password"] is None + + +@pytest.mark.anyio async def test_failed_access_is_aggregated(auth_app, db_session): async with AsyncClient(transport=ASGITransport(app=auth_app), base_url="http://test") as client: assert (await client.get("/api/v1/me")).status_code == 401 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 1746a7d..58d3ea8 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -3,7 +3,7 @@ from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client -from gnexus_creds.models import ApiToken +from gnexus_creds.models import ApiToken, AuditEvent from gnexus_creds.schemas import ApiTokenCreate, Scope, SecretCreate, SecretFieldIn from gnexus_creds.services import Actor, create_api_token, create_secret @@ -53,6 +53,60 @@ @pytest.mark.anyio +async def test_mcp_search_excludes_archived_and_reveal_audits(app, db_session, actor): + actor.channel = "mcp" + actor.api_token = ApiToken( + user_id=actor.user.id, + public_id="mcp-full", + name="mcp-full", + token_hash="hash", + scopes=["mcp", "read", "reveal"], + ) + visible = create_secret( + db_session, + Actor(user=actor.user, channel="ui"), + SecretCreate( + title="Visible MCP", + allow_mcp=True, + fields=[SecretFieldIn(name="password", value="visible", encrypted=True)], + ), + ) + create_secret( + db_session, + Actor(user=actor.user, channel="ui"), + SecretCreate( + title="Archived MCP", + archived=True, + allow_mcp=True, + fields=[SecretFieldIn(name="password", value="archived", encrypted=True)], + ), + ) + db_session.commit() + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + search_response = await client.post( + "/mcp/tools/search_secrets", + json={"arguments": {"q": "MCP"}}, + ) + reveal_response = await client.post( + "/mcp/tools/reveal_secret", + json={"arguments": {"secret_id": str(visible.id)}}, + ) + + assert search_response.status_code == 200 + assert search_response.json()["total"] == 1 + assert search_response.json()["items"][0]["title"] == "Visible MCP" + assert reveal_response.status_code == 200 + + event = ( + db_session.query(AuditEvent) + .filter(AuditEvent.action == "secret.revealed", AuditEvent.secret_id == visible.id) + .one() + ) + assert event.channel == "mcp" + + +@pytest.mark.anyio async def test_mcp_protocol_streamable_http_tools(session_factory, db_session, user, monkeypatch): monkeypatch.setattr("gnexus_creds.mcp_protocol.SessionLocal", session_factory)