Add durable credential registry and secret lifecycle
This commit is contained in:
parent
9b6c3dc71f
commit
ac2289bd0a
28 changed files with 4499 additions and 4 deletions
|
|
@ -1,2 +1,9 @@
|
||||||
[profile.default]
|
[profile.default]
|
||||||
fail-fast = false
|
fail-fast = false
|
||||||
|
|
||||||
|
[test-groups.credentials-reconciliation]
|
||||||
|
max-threads = 1
|
||||||
|
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(remora-credentials) & binary(substrate)'
|
||||||
|
test-group = 'credentials-reconciliation'
|
||||||
|
|
|
||||||
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -1546,6 +1546,23 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "remora-credentials"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"remora-domain",
|
||||||
|
"remora-durable",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
|
"sqlx",
|
||||||
|
"thiserror",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "remora-domain"
|
name = "remora-domain"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ members = [
|
||||||
"crates/remora-api",
|
"crates/remora-api",
|
||||||
"crates/remora-contracts",
|
"crates/remora-contracts",
|
||||||
"crates/remora-coordination",
|
"crates/remora-coordination",
|
||||||
|
"crates/remora-credentials",
|
||||||
"crates/remora-domain",
|
"crates/remora-domain",
|
||||||
"crates/remora-durable",
|
"crates/remora-durable",
|
||||||
"crates/remora-identity",
|
"crates/remora-identity",
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,26 @@ COPY crates crates
|
||||||
COPY --from=kind-identity-build /tmp/remora-identity-tests.tar.zst /tests/remora-identity-tests.tar.zst
|
COPY --from=kind-identity-build /tmp/remora-identity-tests.tar.zst /tests/remora-identity-tests.tar.zst
|
||||||
CMD ["bash", "-ec", "test -s /run/remora-fixture/profile.env && test -n \"$DATABASE_URL\" && test -n \"$REMORA_OIDC_ISSUER\" && export REMORA_IDENTITY_REQUIRE=1 && cargo-nextest nextest run --archive-file /tests/remora-identity-tests.tar.zst --workspace-remap /workspace"]
|
CMD ["bash", "-ec", "test -s /run/remora-fixture/profile.env && test -n \"$DATABASE_URL\" && test -n \"$REMORA_OIDC_ISSUER\" && export REMORA_IDENTITY_REQUIRE=1 && cargo-nextest nextest run --archive-file /tests/remora-identity-tests.tar.zst --workspace-remap /workspace"]
|
||||||
|
|
||||||
|
FROM rust-tools AS kind-credentials-build
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
|
||||||
|
COPY .config .config
|
||||||
|
COPY contracts contracts
|
||||||
|
COPY crates crates
|
||||||
|
COPY docs/api docs/api
|
||||||
|
RUN cargo nextest archive \
|
||||||
|
--package remora-credentials \
|
||||||
|
--build-jobs 4 \
|
||||||
|
--locked \
|
||||||
|
--archive-file /tmp/remora-credentials-tests.tar.zst
|
||||||
|
|
||||||
|
FROM rust-tools AS kind-credentials-runner
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY Cargo.toml ./
|
||||||
|
COPY crates crates
|
||||||
|
COPY --from=kind-credentials-build /tmp/remora-credentials-tests.tar.zst /tests/remora-credentials-tests.tar.zst
|
||||||
|
CMD ["bash", "-ec", "test -s /run/remora-fixture/profile.env && test -n \"$DATABASE_URL\" && test -n \"$REMORA_VAULT_ADDR\" && test -n \"$REMORA_VAULT_TOKEN\" && test -n \"$REMORA_VAULT_PATH\" && export REMORA_CREDENTIALS_REQUIRE=1 && cargo-nextest nextest run --archive-file /tests/remora-credentials-tests.tar.zst --workspace-remap /workspace"]
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS node-runtime
|
FROM ${NODE_IMAGE} AS node-runtime
|
||||||
|
|
||||||
FROM ${PLAYWRIGHT_IMAGE} AS web-dependencies
|
FROM ${PLAYWRIGHT_IMAGE} AS web-dependencies
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: check-dependencies check-fixture-lib check-kind-sample check-durable check-coordination check-identity check-fast check-full check-fast-host check-full-host rust-check web-install web-check web-test-fast web-test-full
|
.PHONY: check-dependencies check-fixture-lib check-kind-sample check-durable check-coordination check-identity check-credentials check-fast check-full check-fast-host check-full-host rust-check web-install web-check web-test-fast web-test-full
|
||||||
|
|
||||||
CONTAINER_ENGINE ?= nerdctl
|
CONTAINER_ENGINE ?= nerdctl
|
||||||
CONTAINER_RUN_FLAGS ?= --memory=16g --memory-swap=24g --cpus=4
|
CONTAINER_RUN_FLAGS ?= --memory=16g --memory-swap=24g --cpus=4
|
||||||
|
|
@ -64,3 +64,6 @@ check-coordination:
|
||||||
|
|
||||||
check-identity:
|
check-identity:
|
||||||
./tests/kind/run-identity-job.sh
|
./tests/kind/run-identity-job.sh
|
||||||
|
|
||||||
|
check-credentials:
|
||||||
|
./tests/kind/run-credentials-job.sh
|
||||||
|
|
|
||||||
25
crates/remora-credentials/Cargo.toml
Normal file
25
crates/remora-credentials/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[package]
|
||||||
|
name = "remora-credentials"
|
||||||
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
rust-version.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
getrandom = "0.3.4"
|
||||||
|
remora-domain.workspace = true
|
||||||
|
remora-durable.workspace = true
|
||||||
|
reqwest = { version = "0.12.28", default-features = false, features = [
|
||||||
|
"json",
|
||||||
|
"rustls-tls-webpki-roots",
|
||||||
|
] }
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
time.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }
|
||||||
53
crates/remora-credentials/migrations/0001_credentials.sql
Normal file
53
crates/remora-credentials/migrations/0001_credentials.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
-- Source credential registry metadata. Secret material lives in Vault KV v2;
|
||||||
|
-- this package only stores owner, kind, status, and opaque version coordinates.
|
||||||
|
|
||||||
|
CREATE TABLE source_credentials (
|
||||||
|
credential_id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
active_version_id TEXT,
|
||||||
|
secret_mount TEXT NOT NULL,
|
||||||
|
secret_path TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
CONSTRAINT source_credentials_kind_check CHECK (
|
||||||
|
kind IN (
|
||||||
|
'basic_auth',
|
||||||
|
'bearer_token',
|
||||||
|
'http_headers',
|
||||||
|
'object_store',
|
||||||
|
'hugging_face'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT source_credentials_status_check CHECK (status IN ('active', 'revoked')),
|
||||||
|
CONSTRAINT source_credentials_label_len CHECK (
|
||||||
|
char_length(label) BETWEEN 1 AND 200
|
||||||
|
),
|
||||||
|
CONSTRAINT source_credentials_active_status CHECK (
|
||||||
|
(status = 'active' AND active_version_id IS NOT NULL)
|
||||||
|
OR (status = 'revoked')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX source_credentials_owner_created_idx
|
||||||
|
ON source_credentials (owner_id, created_at DESC, credential_id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE source_credential_versions (
|
||||||
|
version_id TEXT PRIMARY KEY,
|
||||||
|
credential_id TEXT NOT NULL REFERENCES source_credentials (credential_id),
|
||||||
|
vault_kv_version BIGINT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
destroyed_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT source_credential_versions_kv_positive CHECK (vault_kv_version >= 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX source_credential_versions_credential_idx
|
||||||
|
ON source_credential_versions (credential_id, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE source_credentials
|
||||||
|
ADD CONSTRAINT source_credentials_active_version_fk
|
||||||
|
FOREIGN KEY (active_version_id)
|
||||||
|
REFERENCES source_credential_versions (version_id)
|
||||||
|
DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
-- Durable intent for Vault secret writes. Secret material is never stored here.
|
||||||
|
-- Intents make create/rotate recoverable across ambiguous PostgreSQL commits
|
||||||
|
-- without best-effort Vault destruction that could erase a committed version.
|
||||||
|
|
||||||
|
CREATE TABLE secret_write_intents (
|
||||||
|
intent_id TEXT PRIMARY KEY,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
credential_id TEXT NOT NULL,
|
||||||
|
version_id TEXT NOT NULL,
|
||||||
|
owner_id TEXT,
|
||||||
|
label TEXT,
|
||||||
|
credential_kind TEXT,
|
||||||
|
secret_mount TEXT NOT NULL,
|
||||||
|
secret_path TEXT NOT NULL,
|
||||||
|
vault_kv_version BIGINT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL,
|
||||||
|
CONSTRAINT secret_write_intents_operation_check CHECK (
|
||||||
|
operation IN ('create', 'rotate')
|
||||||
|
),
|
||||||
|
CONSTRAINT secret_write_intents_status_check CHECK (
|
||||||
|
status IN ('prepared', 'vault_written', 'durable_committed', 'abandoned')
|
||||||
|
),
|
||||||
|
CONSTRAINT secret_write_intents_kv_check CHECK (
|
||||||
|
vault_kv_version IS NULL OR vault_kv_version >= 1
|
||||||
|
),
|
||||||
|
CONSTRAINT secret_write_intents_create_fields_check CHECK (
|
||||||
|
(operation = 'create'
|
||||||
|
AND owner_id IS NOT NULL
|
||||||
|
AND label IS NOT NULL
|
||||||
|
AND credential_kind IS NOT NULL)
|
||||||
|
OR (operation = 'rotate'
|
||||||
|
AND owner_id IS NULL
|
||||||
|
AND label IS NULL
|
||||||
|
AND credential_kind IS NULL)
|
||||||
|
),
|
||||||
|
CONSTRAINT secret_write_intents_written_has_version CHECK (
|
||||||
|
(status = 'prepared' AND vault_kv_version IS NULL)
|
||||||
|
OR (status IN ('vault_written', 'durable_committed')
|
||||||
|
AND vault_kv_version IS NOT NULL)
|
||||||
|
OR status = 'abandoned'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX secret_write_intents_status_idx
|
||||||
|
ON secret_write_intents (status, created_at);
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- Durable execution-grant credential pins. Resolution always re-reads this table:
|
||||||
|
-- revoked or missing grants fail closed even if a worker still holds a grant id.
|
||||||
|
|
||||||
|
CREATE TABLE execution_grant_credential_pins (
|
||||||
|
grant_id TEXT PRIMARY KEY,
|
||||||
|
credential_id TEXT NOT NULL,
|
||||||
|
version_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT execution_grant_credential_pins_status_check CHECK (
|
||||||
|
status IN ('active', 'revoked')
|
||||||
|
),
|
||||||
|
CONSTRAINT execution_grant_credential_pins_revoked_at_check CHECK (
|
||||||
|
(status = 'active' AND revoked_at IS NULL)
|
||||||
|
OR (status = 'revoked' AND revoked_at IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX execution_grant_credential_pins_credential_idx
|
||||||
|
ON execution_grant_credential_pins (credential_id, status);
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
-- Reconciler ownership claim: vault destruction runs only after an intent is
|
||||||
|
-- moved to abandoning under the credential lock. Create/rotate materialize
|
||||||
|
-- refuses non-vault_written intents, so a concurrent commit cannot land a
|
||||||
|
-- durable version that points at material the reconciler is about to destroy.
|
||||||
|
|
||||||
|
ALTER TABLE secret_write_intents
|
||||||
|
DROP CONSTRAINT secret_write_intents_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE secret_write_intents
|
||||||
|
ADD CONSTRAINT secret_write_intents_status_check CHECK (
|
||||||
|
status IN (
|
||||||
|
'prepared',
|
||||||
|
'vault_written',
|
||||||
|
'durable_committed',
|
||||||
|
'abandoning',
|
||||||
|
'abandoned'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE secret_write_intents
|
||||||
|
DROP CONSTRAINT secret_write_intents_written_has_version;
|
||||||
|
|
||||||
|
ALTER TABLE secret_write_intents
|
||||||
|
ADD CONSTRAINT secret_write_intents_written_has_version CHECK (
|
||||||
|
(status = 'prepared' AND vault_kv_version IS NULL)
|
||||||
|
OR (status IN ('vault_written', 'durable_committed', 'abandoning')
|
||||||
|
AND vault_kv_version IS NOT NULL)
|
||||||
|
OR status = 'abandoned'
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
ALTER TABLE source_credential_versions
|
||||||
|
ADD COLUMN secret_mount TEXT,
|
||||||
|
ADD COLUMN secret_path TEXT;
|
||||||
|
|
||||||
|
UPDATE source_credential_versions AS version
|
||||||
|
SET secret_mount = credential.secret_mount,
|
||||||
|
secret_path = credential.secret_path
|
||||||
|
FROM source_credentials AS credential
|
||||||
|
WHERE credential.credential_id = version.credential_id;
|
||||||
|
|
||||||
|
ALTER TABLE source_credential_versions
|
||||||
|
ALTER COLUMN secret_mount SET NOT NULL,
|
||||||
|
ALTER COLUMN secret_path SET NOT NULL;
|
||||||
28
crates/remora-credentials/src/authz.rs
Normal file
28
crates/remora-credentials/src/authz.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
use remora_domain::OpaqueId;
|
||||||
|
|
||||||
|
use crate::error::CredentialError;
|
||||||
|
use crate::types::SourceCredential;
|
||||||
|
|
||||||
|
/// Caller identity for credential registry authorization.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CredentialCaller {
|
||||||
|
pub principal_id: OpaqueId,
|
||||||
|
pub administrator: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner or administrator may manage a credential; peers see NotFound.
|
||||||
|
#[must_use]
|
||||||
|
pub fn may_manage(caller: &CredentialCaller, owner: &OpaqueId) -> bool {
|
||||||
|
caller.administrator || caller.principal_id == *owner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map missing and unauthorized credentials to the same NotFound outcome.
|
||||||
|
pub fn require_manage(
|
||||||
|
caller: &CredentialCaller,
|
||||||
|
credential: Option<&SourceCredential>,
|
||||||
|
) -> Result<SourceCredential, CredentialError> {
|
||||||
|
match credential {
|
||||||
|
Some(record) if may_manage(caller, &record.owner) => Ok(record.clone()),
|
||||||
|
Some(_) | None => Err(CredentialError::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
40
crates/remora-credentials/src/error.rs
Normal file
40
crates/remora-credentials/src/error.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use remora_domain::IdError;
|
||||||
|
use remora_durable::DurableError;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum CredentialError {
|
||||||
|
#[error("credential not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("credential is revoked")]
|
||||||
|
Revoked,
|
||||||
|
#[error("credential has no pinable version")]
|
||||||
|
NoActiveVersion,
|
||||||
|
#[error("invalid credential input: {0}")]
|
||||||
|
InvalidInput(String),
|
||||||
|
#[error("credential material does not match stored kind")]
|
||||||
|
KindMismatch,
|
||||||
|
#[error("execution grant is not active")]
|
||||||
|
GrantInactive,
|
||||||
|
#[error("credential version is not resolvable")]
|
||||||
|
Unresolvable,
|
||||||
|
#[error("secret store unavailable")]
|
||||||
|
SecretStoreUnavailable,
|
||||||
|
#[error("configuration error: {0}")]
|
||||||
|
Config(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Durable(#[from] DurableError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<IdError> for CredentialError {
|
||||||
|
fn from(_: IdError) -> Self {
|
||||||
|
Self::InvalidInput("invalid opaque identifier".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialError {
|
||||||
|
/// Errors never embed secret material; this helper keeps that invariant explicit.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_safe_for_logs(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/remora-credentials/src/ids.rs
Normal file
30
crates/remora-credentials/src/ids.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use remora_domain::OpaqueId;
|
||||||
|
|
||||||
|
use crate::error::CredentialError;
|
||||||
|
|
||||||
|
pub fn new_credential_id() -> Result<OpaqueId, CredentialError> {
|
||||||
|
new_id('c')
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_version_id() -> Result<OpaqueId, CredentialError> {
|
||||||
|
new_id('v')
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_intent_id() -> Result<OpaqueId, CredentialError> {
|
||||||
|
new_id('i')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_id(prefix: char) -> Result<OpaqueId, CredentialError> {
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
getrandom::fill(&mut bytes).map_err(|error| {
|
||||||
|
CredentialError::Config(format!("failed to draw opaque identifier entropy: {error}"))
|
||||||
|
})?;
|
||||||
|
let mut hex = String::with_capacity(32);
|
||||||
|
for byte in bytes {
|
||||||
|
hex.push(char::from(HEX[(byte >> 4) as usize]));
|
||||||
|
hex.push(char::from(HEX[(byte & 0x0f) as usize]));
|
||||||
|
}
|
||||||
|
OpaqueId::parse(format!("{prefix}{hex}")).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
29
crates/remora-credentials/src/lib.rs
Normal file
29
crates/remora-credentials/src/lib.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
//! Source credential registry and grant-bound secret resolution.
|
||||||
|
//!
|
||||||
|
//! Durable metadata and authorization state live in PostgreSQL. Secret material
|
||||||
|
//! is stored in Vault KV v2 behind server-allocated paths and immutable versions.
|
||||||
|
//! Submission pins the current authorized version into an execution grant; the
|
||||||
|
//! trusted resolver accepts only an ActiveCredentialGrant capability formed
|
||||||
|
//! after durable grant activity is verified.
|
||||||
|
|
||||||
|
mod authz;
|
||||||
|
mod error;
|
||||||
|
mod ids;
|
||||||
|
mod migrate;
|
||||||
|
mod registry;
|
||||||
|
mod resolve;
|
||||||
|
mod types;
|
||||||
|
mod vault;
|
||||||
|
|
||||||
|
pub use authz::{CredentialCaller, may_manage, require_manage};
|
||||||
|
pub use error::CredentialError;
|
||||||
|
pub use migrate::{migrate, migrator};
|
||||||
|
pub use registry::CredentialRegistry;
|
||||||
|
pub use resolve::GrantBoundResolver;
|
||||||
|
pub use types::{
|
||||||
|
CreateCredential, CredentialGrantVersion, SecretMaterial, SourceCredential,
|
||||||
|
SourceCredentialKind, SourceCredentialPage, SourceCredentialStatus, SourceCredentialVersion,
|
||||||
|
};
|
||||||
|
pub use vault::{LiveVaultVersion, VaultClient, VaultConfig, split_vault_path};
|
||||||
|
|
||||||
|
pub use remora_domain::ports::ActiveCredentialGrant;
|
||||||
19
crates/remora-credentials/src/migrate.rs
Normal file
19
crates/remora-credentials/src/migrate.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
use remora_durable::DurableError;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
/// Separate migration history from the DurableStore substrate so feature-package
|
||||||
|
/// versions do not collide with `remora-durable` in `_sqlx_migrations`.
|
||||||
|
const MIGRATION_TABLE: &str = "_remora_credentials_migrations";
|
||||||
|
|
||||||
|
pub async fn migrate(pool: &PgPool) -> Result<(), DurableError> {
|
||||||
|
let mut migrator = sqlx::migrate!("./migrations");
|
||||||
|
migrator.dangerous_set_table_name(MIGRATION_TABLE);
|
||||||
|
migrator.run(pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn migrator() -> sqlx::migrate::Migrator {
|
||||||
|
let mut migrator = sqlx::migrate!("./migrations");
|
||||||
|
migrator.dangerous_set_table_name(MIGRATION_TABLE);
|
||||||
|
migrator
|
||||||
|
}
|
||||||
1510
crates/remora-credentials/src/registry.rs
Normal file
1510
crates/remora-credentials/src/registry.rs
Normal file
File diff suppressed because it is too large
Load diff
62
crates/remora-credentials/src/resolve.rs
Normal file
62
crates/remora-credentials/src/resolve.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
use remora_domain::ports::{ActiveCredentialGrant, CredentialResolver};
|
||||||
|
|
||||||
|
use crate::error::CredentialError;
|
||||||
|
use crate::registry::CredentialRegistry;
|
||||||
|
use crate::types::SecretMaterial;
|
||||||
|
use crate::vault::VaultClient;
|
||||||
|
|
||||||
|
/// Grant-bound credential resolver.
|
||||||
|
///
|
||||||
|
/// Every resolve reloads durable execution-grant pin state. Unknown, revoked,
|
||||||
|
/// or destroyed pins fail closed. Callers cannot resolve by forging bare
|
||||||
|
/// credential/version IDs.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct GrantBoundResolver {
|
||||||
|
registry: CredentialRegistry,
|
||||||
|
vault: VaultClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GrantBoundResolver {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(registry: CredentialRegistry, vault: VaultClient) -> Self {
|
||||||
|
Self { registry, vault }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_access(
|
||||||
|
&self,
|
||||||
|
access: &ActiveCredentialGrant,
|
||||||
|
) -> Result<SecretMaterial, CredentialError> {
|
||||||
|
let pin = self
|
||||||
|
.registry
|
||||||
|
.load_active_grant_pin(access.grant_id())
|
||||||
|
.await?
|
||||||
|
.ok_or(CredentialError::GrantInactive)?;
|
||||||
|
let coord = self
|
||||||
|
.registry
|
||||||
|
.load_version_coord(&pin.credential_id, &pin.version_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(CredentialError::Unresolvable)?;
|
||||||
|
if coord.destroyed {
|
||||||
|
return Err(CredentialError::Unresolvable);
|
||||||
|
}
|
||||||
|
self.vault
|
||||||
|
.get_version(
|
||||||
|
&coord.secret_mount,
|
||||||
|
&coord.secret_path,
|
||||||
|
coord.vault_kv_version,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CredentialResolver for GrantBoundResolver {
|
||||||
|
type Error = CredentialError;
|
||||||
|
type Secret = SecretMaterial;
|
||||||
|
|
||||||
|
fn resolve(
|
||||||
|
&self,
|
||||||
|
access: &ActiveCredentialGrant,
|
||||||
|
) -> impl std::future::Future<Output = Result<Self::Secret, Self::Error>> + Send {
|
||||||
|
self.resolve_access(access)
|
||||||
|
}
|
||||||
|
}
|
||||||
263
crates/remora-credentials/src/types.rs
Normal file
263
crates/remora-credentials/src/types.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use remora_domain::OpaqueId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SourceCredentialKind {
|
||||||
|
BasicAuth,
|
||||||
|
BearerToken,
|
||||||
|
HttpHeaders,
|
||||||
|
ObjectStore,
|
||||||
|
HuggingFace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceCredentialKind {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::BasicAuth => "basic_auth",
|
||||||
|
Self::BearerToken => "bearer_token",
|
||||||
|
Self::HttpHeaders => "http_headers",
|
||||||
|
Self::ObjectStore => "object_store",
|
||||||
|
Self::HuggingFace => "hugging_face",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Result<Self, super::error::CredentialError> {
|
||||||
|
match value {
|
||||||
|
"basic_auth" => Ok(Self::BasicAuth),
|
||||||
|
"bearer_token" => Ok(Self::BearerToken),
|
||||||
|
"http_headers" => Ok(Self::HttpHeaders),
|
||||||
|
"object_store" => Ok(Self::ObjectStore),
|
||||||
|
"hugging_face" => Ok(Self::HuggingFace),
|
||||||
|
other => Err(super::error::CredentialError::InvalidInput(format!(
|
||||||
|
"unknown credential kind '{other}'"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SourceCredentialStatus {
|
||||||
|
Active,
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceCredentialStatus {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Active => "active",
|
||||||
|
Self::Revoked => "revoked",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Result<Self, super::error::CredentialError> {
|
||||||
|
match value {
|
||||||
|
"active" => Ok(Self::Active),
|
||||||
|
"revoked" => Ok(Self::Revoked),
|
||||||
|
other => Err(super::error::CredentialError::InvalidInput(format!(
|
||||||
|
"unknown credential status '{other}'"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write-only secret material. Debug and Display never include values.
|
||||||
|
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum SecretMaterial {
|
||||||
|
BasicAuth {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
|
BearerToken {
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
HttpHeaders {
|
||||||
|
headers: BTreeMap<String, String>,
|
||||||
|
},
|
||||||
|
ObjectStore {
|
||||||
|
access_key_id: String,
|
||||||
|
secret_access_key: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
session_token: Option<String>,
|
||||||
|
},
|
||||||
|
HuggingFace {
|
||||||
|
token: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretMaterial {
|
||||||
|
#[must_use]
|
||||||
|
pub fn kind(&self) -> SourceCredentialKind {
|
||||||
|
match self {
|
||||||
|
Self::BasicAuth { .. } => SourceCredentialKind::BasicAuth,
|
||||||
|
Self::BearerToken { .. } => SourceCredentialKind::BearerToken,
|
||||||
|
Self::HttpHeaders { .. } => SourceCredentialKind::HttpHeaders,
|
||||||
|
Self::ObjectStore { .. } => SourceCredentialKind::ObjectStore,
|
||||||
|
Self::HuggingFace { .. } => SourceCredentialKind::HuggingFace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), super::error::CredentialError> {
|
||||||
|
match self {
|
||||||
|
Self::BasicAuth { username, password } => {
|
||||||
|
require_nonempty("username", username, 1024)?;
|
||||||
|
require_nonempty("password", password, 16_384)?;
|
||||||
|
}
|
||||||
|
Self::BearerToken { token } | Self::HuggingFace { token } => {
|
||||||
|
require_nonempty("token", token, 16_384)?;
|
||||||
|
}
|
||||||
|
Self::HttpHeaders { headers } => {
|
||||||
|
if headers.is_empty() || headers.len() > 64 {
|
||||||
|
return Err(super::error::CredentialError::InvalidInput(
|
||||||
|
"headers must contain between 1 and 64 entries".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (name, value) in headers {
|
||||||
|
validate_header_name(name)?;
|
||||||
|
require_nonempty("header value", value, 16_384)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::ObjectStore {
|
||||||
|
access_key_id,
|
||||||
|
secret_access_key,
|
||||||
|
session_token,
|
||||||
|
} => {
|
||||||
|
require_nonempty("access_key_id", access_key_id, 1024)?;
|
||||||
|
require_nonempty("secret_access_key", secret_access_key, 16_384)?;
|
||||||
|
if let Some(token) = session_token {
|
||||||
|
require_nonempty("session_token", token, 16_384)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for SecretMaterial {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::BasicAuth { .. } => formatter.write_str("SecretMaterial::BasicAuth(REDACTED)"),
|
||||||
|
Self::BearerToken { .. } => {
|
||||||
|
formatter.write_str("SecretMaterial::BearerToken(REDACTED)")
|
||||||
|
}
|
||||||
|
Self::HttpHeaders { headers } => formatter
|
||||||
|
.debug_struct("SecretMaterial::HttpHeaders")
|
||||||
|
.field("count", &headers.len())
|
||||||
|
.field("values", &"REDACTED")
|
||||||
|
.finish(),
|
||||||
|
Self::ObjectStore { .. } => {
|
||||||
|
formatter.write_str("SecretMaterial::ObjectStore(REDACTED)")
|
||||||
|
}
|
||||||
|
Self::HuggingFace { .. } => {
|
||||||
|
formatter.write_str("SecretMaterial::HuggingFace(REDACTED)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for SecretMaterial {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(formatter, "SecretMaterial({})", self.kind().as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SourceCredentialVersion {
|
||||||
|
pub id: OpaqueId,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque application pin carried by an immutable execution grant.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CredentialGrantVersion {
|
||||||
|
pub credential_id: OpaqueId,
|
||||||
|
pub version_id: OpaqueId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata projection; never includes secret material or Vault coordinates.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SourceCredential {
|
||||||
|
pub id: OpaqueId,
|
||||||
|
pub owner: OpaqueId,
|
||||||
|
pub label: String,
|
||||||
|
pub kind: SourceCredentialKind,
|
||||||
|
pub status: SourceCredentialStatus,
|
||||||
|
pub active_version: Option<SourceCredentialVersion>,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SourceCredentialPage {
|
||||||
|
pub items: Vec<SourceCredential>,
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CreateCredential {
|
||||||
|
pub label: String,
|
||||||
|
pub material: SecretMaterial,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_nonempty(
|
||||||
|
field: &str,
|
||||||
|
value: &str,
|
||||||
|
max: usize,
|
||||||
|
) -> Result<(), super::error::CredentialError> {
|
||||||
|
if value.is_empty() || value.len() > max {
|
||||||
|
return Err(super::error::CredentialError::InvalidInput(format!(
|
||||||
|
"{field} length must be between 1 and {max}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_header_name(name: &str) -> Result<(), super::error::CredentialError> {
|
||||||
|
if name.is_empty()
|
||||||
|
|| !name.bytes().all(|b| {
|
||||||
|
matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'!' | b'#' | b'$' | b'%' | b'&'
|
||||||
|
| b'\'' | b'*' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~')
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(super::error::CredentialError::InvalidInput(
|
||||||
|
"header names must be lowercase token characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
const FORBIDDEN: &[&str] = &[
|
||||||
|
"authorization",
|
||||||
|
"connection",
|
||||||
|
"content-length",
|
||||||
|
"cookie",
|
||||||
|
"host",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
];
|
||||||
|
if FORBIDDEN.contains(&name) {
|
||||||
|
return Err(super::error::CredentialError::InvalidInput(format!(
|
||||||
|
"header '{name}' is not permitted"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_label(label: &str) -> Result<(), super::error::CredentialError> {
|
||||||
|
if label.is_empty() || label.len() > 200 {
|
||||||
|
return Err(super::error::CredentialError::InvalidInput(
|
||||||
|
"label length must be between 1 and 200".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
460
crates/remora-credentials/src/vault.rs
Normal file
460
crates/remora-credentials/src/vault.rs
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
use std::fmt;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use remora_domain::OpaqueId;
|
||||||
|
use reqwest::redirect::Policy;
|
||||||
|
use reqwest::{Client, StatusCode};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::CredentialError;
|
||||||
|
use crate::types::SecretMaterial;
|
||||||
|
|
||||||
|
/// One non-destroyed KV v2 version observed via secret metadata.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct LiveVaultVersion {
|
||||||
|
pub version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum EmptyPathFence {
|
||||||
|
Created(u64),
|
||||||
|
Occupied,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vault KV v2 access for credential secret material.
|
||||||
|
///
|
||||||
|
/// Topology and auth method are deployment choices. This client accepts a token
|
||||||
|
/// (fixture root token or workload token after Kubernetes auth) and never logs
|
||||||
|
/// request or response bodies that contain secret material.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct VaultConfig {
|
||||||
|
pub address: String,
|
||||||
|
pub token: String,
|
||||||
|
/// KV v2 mount, e.g. `secret`.
|
||||||
|
pub mount: String,
|
||||||
|
/// Path prefix under the mount where this deployment may allocate secrets.
|
||||||
|
pub path_prefix: String,
|
||||||
|
pub timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for VaultConfig {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("VaultConfig")
|
||||||
|
.field("address", &self.address)
|
||||||
|
.field("mount", &self.mount)
|
||||||
|
.field("path_prefix", &self.path_prefix)
|
||||||
|
.field("timeout", &self.timeout)
|
||||||
|
.field("token", &"REDACTED")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultConfig {
|
||||||
|
pub fn from_env() -> Result<Self, CredentialError> {
|
||||||
|
let address = required_env("REMORA_VAULT_ADDR")?;
|
||||||
|
let token = required_env("REMORA_VAULT_TOKEN")?;
|
||||||
|
let full_path = required_env("REMORA_VAULT_PATH")?;
|
||||||
|
let (mount, path_prefix) = split_vault_path(&full_path)?;
|
||||||
|
Ok(Self {
|
||||||
|
address,
|
||||||
|
token,
|
||||||
|
mount,
|
||||||
|
path_prefix,
|
||||||
|
timeout: Duration::from_secs(10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate the opaque secret path for one credential version identity.
|
||||||
|
#[must_use]
|
||||||
|
pub fn secret_path_for(&self, credential_id: &OpaqueId, version_id: &OpaqueId) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/versions/{}",
|
||||||
|
self.credential_path_for(credential_id),
|
||||||
|
version_id.as_str()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn credential_path_for(&self, credential_id: &OpaqueId) -> String {
|
||||||
|
if self.path_prefix.is_empty() {
|
||||||
|
format!("credentials/{}", credential_id.as_str())
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}/credentials/{}",
|
||||||
|
self.path_prefix.trim_matches('/'),
|
||||||
|
credential_id.as_str()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct VaultClient {
|
||||||
|
http: Client,
|
||||||
|
config: VaultConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for VaultClient {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("VaultClient")
|
||||||
|
.field("config", &self.config)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultClient {
|
||||||
|
pub fn new(config: VaultConfig) -> Result<Self, CredentialError> {
|
||||||
|
// Disable redirects and ambient proxy discovery so X-Vault-Token and
|
||||||
|
// secret write bodies cannot leave the configured Vault endpoint via
|
||||||
|
// hop rewriting or HTTP_PROXY/HTTPS_PROXY injection.
|
||||||
|
let http = Client::builder()
|
||||||
|
.timeout(config.timeout)
|
||||||
|
.redirect(Policy::none())
|
||||||
|
.no_proxy()
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
CredentialError::Config(format!("failed to build vault HTTP client: {error}"))
|
||||||
|
})?;
|
||||||
|
Ok(Self { http, config })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn config(&self) -> &VaultConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create secret material and return the immutable KV v2 version number.
|
||||||
|
///
|
||||||
|
/// The write uses CAS 0 so an existing intent-bound path is never overwritten.
|
||||||
|
pub async fn put_version(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
secret_path: &str,
|
||||||
|
material: &SecretMaterial,
|
||||||
|
) -> Result<u64, CredentialError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/v1/{}/data/{}",
|
||||||
|
self.config.address.trim_end_matches('/'),
|
||||||
|
mount.trim_matches('/'),
|
||||||
|
secret_path.trim_start_matches('/')
|
||||||
|
);
|
||||||
|
let body = KvWriteRequest {
|
||||||
|
data: material.clone(),
|
||||||
|
options: KvWriteOptions { cas: 0 },
|
||||||
|
};
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Vault-Token", &self.config.token)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(map_transport)?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let _ = response.bytes().await;
|
||||||
|
return Err(map_http_status(status, "write secret version"));
|
||||||
|
}
|
||||||
|
let parsed: KvWriteResponse = response.json().await.map_err(map_transport)?;
|
||||||
|
let version = parsed.data.and_then(|data| data.version).ok_or_else(|| {
|
||||||
|
CredentialError::Config("vault write response omitted version".into())
|
||||||
|
})?;
|
||||||
|
if version < 1 {
|
||||||
|
return Err(CredentialError::Config(
|
||||||
|
"vault returned non-positive secret version".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn fence_empty_path(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
secret_path: &str,
|
||||||
|
) -> Result<EmptyPathFence, CredentialError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/v1/{}/data/{}",
|
||||||
|
self.config.address.trim_end_matches('/'),
|
||||||
|
mount.trim_matches('/'),
|
||||||
|
secret_path.trim_start_matches('/')
|
||||||
|
);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Vault-Token", &self.config.token)
|
||||||
|
.json(&FenceWriteRequest {
|
||||||
|
data: FenceMarker {
|
||||||
|
remora_write_fence: true,
|
||||||
|
},
|
||||||
|
options: KvWriteOptions { cas: 0 },
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(map_transport)?;
|
||||||
|
let status = response.status();
|
||||||
|
if status == StatusCode::BAD_REQUEST {
|
||||||
|
let _ = response.bytes().await;
|
||||||
|
return Ok(EmptyPathFence::Occupied);
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
let _ = response.bytes().await;
|
||||||
|
return Err(map_http_status(status, "fence empty secret path"));
|
||||||
|
}
|
||||||
|
let parsed: KvWriteResponse = response.json().await.map_err(map_transport)?;
|
||||||
|
let version = parsed.data.and_then(|data| data.version).ok_or_else(|| {
|
||||||
|
CredentialError::Config("vault fence response omitted version".into())
|
||||||
|
})?;
|
||||||
|
if version < 1 {
|
||||||
|
return Err(CredentialError::Config(
|
||||||
|
"vault returned non-positive fence version".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(EmptyPathFence::Created(version))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one exact KV v2 version. Missing or destroyed versions fail closed.
|
||||||
|
pub async fn get_version(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
secret_path: &str,
|
||||||
|
vault_kv_version: u64,
|
||||||
|
) -> Result<SecretMaterial, CredentialError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/v1/{}/data/{}?version={vault_kv_version}",
|
||||||
|
self.config.address.trim_end_matches('/'),
|
||||||
|
mount.trim_matches('/'),
|
||||||
|
secret_path.trim_start_matches('/')
|
||||||
|
);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Vault-Token", &self.config.token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(map_transport)?;
|
||||||
|
let status = response.status();
|
||||||
|
if status == StatusCode::NOT_FOUND {
|
||||||
|
let _ = response.bytes().await;
|
||||||
|
return Err(CredentialError::Unresolvable);
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
let _ = response.bytes().await;
|
||||||
|
return Err(map_http_status(status, "read secret version"));
|
||||||
|
}
|
||||||
|
let parsed: KvReadResponse = response.json().await.map_err(map_transport)?;
|
||||||
|
let data = parsed.data.ok_or(CredentialError::Unresolvable)?;
|
||||||
|
let material = data.data.ok_or(CredentialError::Unresolvable)?;
|
||||||
|
let reported = data
|
||||||
|
.metadata
|
||||||
|
.and_then(|meta| meta.version)
|
||||||
|
.ok_or(CredentialError::Unresolvable)?;
|
||||||
|
if reported != vault_kv_version {
|
||||||
|
return Err(CredentialError::Unresolvable);
|
||||||
|
}
|
||||||
|
material.validate()?;
|
||||||
|
Ok(material)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List non-destroyed KV v2 versions present at a path (for intent recovery).
|
||||||
|
///
|
||||||
|
pub async fn list_live_versions(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
secret_path: &str,
|
||||||
|
) -> Result<Vec<LiveVaultVersion>, CredentialError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/v1/{}/metadata/{}",
|
||||||
|
self.config.address.trim_end_matches('/'),
|
||||||
|
mount.trim_matches('/'),
|
||||||
|
secret_path.trim_start_matches('/')
|
||||||
|
);
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("X-Vault-Token", &self.config.token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(map_transport)?;
|
||||||
|
let status = response.status();
|
||||||
|
if status == StatusCode::NOT_FOUND {
|
||||||
|
let _ = response.bytes().await;
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
let _ = response.bytes().await;
|
||||||
|
return Err(map_http_status(status, "list secret metadata"));
|
||||||
|
}
|
||||||
|
let parsed: KvMetadataResponse = response.json().await.map_err(map_transport)?;
|
||||||
|
let versions = parsed
|
||||||
|
.data
|
||||||
|
.and_then(|data| data.versions)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut live = Vec::new();
|
||||||
|
for (key, meta) in versions {
|
||||||
|
if meta.destroyed.unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(version) = key.parse::<u64>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if version < 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
live.push(LiveVaultVersion { version });
|
||||||
|
}
|
||||||
|
live.sort_by_key(|entry| entry.version);
|
||||||
|
Ok(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Irreversibly destroy one KV v2 version.
|
||||||
|
pub async fn destroy_version(
|
||||||
|
&self,
|
||||||
|
mount: &str,
|
||||||
|
secret_path: &str,
|
||||||
|
vault_kv_version: u64,
|
||||||
|
) -> Result<(), CredentialError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/v1/{}/destroy/{}",
|
||||||
|
self.config.address.trim_end_matches('/'),
|
||||||
|
mount.trim_matches('/'),
|
||||||
|
secret_path.trim_start_matches('/')
|
||||||
|
);
|
||||||
|
let body = DestroyRequest {
|
||||||
|
versions: vec![vault_kv_version],
|
||||||
|
};
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Vault-Token", &self.config.token)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(map_transport)?;
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let _ = response.bytes().await;
|
||||||
|
return Err(map_http_status(status, "destroy secret version"));
|
||||||
|
}
|
||||||
|
let _ = response.bytes().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct KvWriteRequest {
|
||||||
|
data: SecretMaterial,
|
||||||
|
options: KvWriteOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct KvWriteOptions {
|
||||||
|
cas: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FenceWriteRequest {
|
||||||
|
data: FenceMarker,
|
||||||
|
options: KvWriteOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct FenceMarker {
|
||||||
|
remora_write_fence: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvWriteResponse {
|
||||||
|
data: Option<KvWriteData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvWriteData {
|
||||||
|
version: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvReadResponse {
|
||||||
|
data: Option<KvReadData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvReadData {
|
||||||
|
data: Option<SecretMaterial>,
|
||||||
|
metadata: Option<KvMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvMetadata {
|
||||||
|
version: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvMetadataResponse {
|
||||||
|
data: Option<KvMetadataListData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvMetadataListData {
|
||||||
|
versions: Option<std::collections::BTreeMap<String, KvVersionMeta>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct KvVersionMeta {
|
||||||
|
destroyed: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DestroyRequest {
|
||||||
|
versions: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_env(name: &str) -> Result<String, CredentialError> {
|
||||||
|
std::env::var(name)
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| CredentialError::Config(format!("{name} is required")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a fixture path such as `secret/remora/lane-0` into mount + prefix.
|
||||||
|
pub fn split_vault_path(full: &str) -> Result<(String, String), CredentialError> {
|
||||||
|
let trimmed = full.trim().trim_matches('/');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(CredentialError::Config(
|
||||||
|
"REMORA_VAULT_PATH must include a mount".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match trimmed.split_once('/') {
|
||||||
|
Some((mount, prefix)) if !mount.is_empty() => {
|
||||||
|
Ok((mount.to_owned(), prefix.trim_matches('/').to_owned()))
|
||||||
|
}
|
||||||
|
Some(_) | None => Ok((trimmed.to_owned(), String::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_transport(_error: reqwest::Error) -> CredentialError {
|
||||||
|
CredentialError::SecretStoreUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_http_status(status: StatusCode, operation: &str) -> CredentialError {
|
||||||
|
if status.is_server_error() || status == StatusCode::SERVICE_UNAVAILABLE {
|
||||||
|
CredentialError::SecretStoreUnavailable
|
||||||
|
} else if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
|
||||||
|
// Treat policy denials as unresolvable rather than leaking store details.
|
||||||
|
CredentialError::Unresolvable
|
||||||
|
} else if status == StatusCode::NOT_FOUND {
|
||||||
|
CredentialError::Unresolvable
|
||||||
|
} else {
|
||||||
|
CredentialError::Config(format!(
|
||||||
|
"vault {operation} failed with HTTP {}",
|
||||||
|
status.as_u16()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue