Skip to content
Draft
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
1 change: 1 addition & 0 deletions languages/python/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions languages/python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ edition = "2021"
repository = "https://github.com/cipherstash/stack"

[workspace.dependencies]
# `default-features = false`: no `http` yet. The skeleton makes no request;
# the ticket that turns `encrypt` live names `http` explicitly on the engine
# crate so a later `default-features = false` cannot remove it silently.
# `dynamic` is the runtime value model every binding speaks (frames, plans
# read at run time), and the only stack-encrypt surface the spec has core use.
# read at run time). `http`, the HTTP client and TLS stack, is off: nothing
# makes a request yet. `Cipher` finds the credentials over a transport that
# sends nothing (`crates/core/src/credentials.rs`). The ticket that makes
# the first request turns it on, here and on stack-auth below.
stack-encrypt = { path = "../../packages/stack-encrypt", default-features = false, features = ["dynamic"] }
# The credentials, as every SDK finds them. By path, the same crate
# stack-encrypt depends on. Its `http` feature is off, as above.
stack-auth = { path = "../../packages/stack-auth", default-features = false }
1 change: 1 addition & 0 deletions languages/python/crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ eql-bindings = { path = "../../../../packages/eql/crates/eql-bindings", features
miette = "7.5.0"
# For the plan's frame: the fields in declaration order.
serde = "1"
stack-auth = { workspace = true }
stack-encrypt = { workspace = true }

[dev-dependencies]
Expand Down
91 changes: 91 additions & 0 deletions languages/python/crates/core/src/credentials.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! The credentials a `Cipher` is built with, found as every SDK finds them.
//!
//! `stack_auth::AutoStrategy` reads `CS_CLIENT_ACCESS_KEY` and
//! `CS_WORKSPACE_CRN`, then the profile store (`~/.cipherstash`, or
//! `CS_CONFIG_PATH`). With a profile and no access key, it reads and parses
//! the profile's token file. It makes no request. The Python tests cover it:
//! a Rust test would set environment variables that other tests in the same
//! process read.

use stack_auth::{AuthError, AutoStrategy, HttpRequest, HttpResponse, HttpTransport, RequestError};

use crate::refusal::Refusal;

/// Check that the credentials are there.
///
/// The strategy `AutoStrategy` finds is built over [`NoRequest`], so no HTTP
/// client, TLS configuration or root store is built, and nothing is kept.
/// stack-auth's `http` feature is off (`Cargo.toml`), so none is compiled
/// either. The ticket that makes the first request turns it on and keeps a
/// strategy with a real transport. The work here is the environment, the
/// service URL for the CRN's region, and one small file where there is a
/// profile.
///
/// # Errors
///
/// The engine's refusal, such as `stack_auth_not_authenticated` when there
/// are no credentials. The message names the variable or file to fix, for
/// a Python user.
pub fn detect() -> Result<(), Refusal> {
AutoStrategy::builder()
.transport(NoRequest)
.detect()
.map(drop)
.map_err(|error| {
let refused = Refusal::of(&error);
Refusal {
message: message(&error).unwrap_or(refused.message.clone()),
..refused
}
})
}

/// The message for a Python user, where the engine's names a Rust API or
/// no variable at all. `None` keeps the engine's message and help.
fn message(error: &AuthError) -> Option<String> {
let text = match error {
AuthError::NotAuthenticated(_) => "no credentials were found. Set CS_CLIENT_ACCESS_KEY \
and CS_WORKSPACE_CRN, or log in with `stash auth login`."
.to_owned(),
AuthError::MissingWorkspaceCrn(_) => {
"CS_CLIENT_ACCESS_KEY is set and CS_WORKSPACE_CRN is not. Set both.".to_owned()
}
AuthError::InvalidAccessKey(_) => {
format!("{error}. An access key has the form CSAK<key-id>.<secret>.")
}
AuthError::InvalidCrn(_) => format!(
"{error}. CS_WORKSPACE_CRN has the form crn:<region>:<workspace-id>, \
such as crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY."
),
AuthError::Region(_) => {
format!("{error}. The region is the middle part of CS_WORKSPACE_CRN.")
}
AuthError::InvalidUrl(_) => format!("{error}. CS_CTS_HOST, when set, must be a URL."),
AuthError::Store(_) => format!(
"{error}. The profile is in ~/.cipherstash, or in CS_CONFIG_PATH when set. \
Log in again with `stash auth login`, or set CS_CLIENT_ACCESS_KEY."
),
_ => return None,
};
Some(text)
}

/// A transport that sends nothing. A strategy cannot be built without one.
struct NoRequest;

impl HttpTransport for NoRequest {
async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, RequestError> {
Err(RequestError(Box::new(Refused)))
}
}

#[derive(Debug)]
struct Refused;

impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("this strategy only checks that the credentials exist; it sends no request")
}
}

impl std::error::Error for Refused {}
9 changes: 6 additions & 3 deletions languages/python/crates/core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
//! The Python SDK's engine-side logic, with no pyo3 in it, so that it can be
//! linked and tested without a Python interpreter.
//!
//! It holds the engine commit the build recorded (`build.rs`), and the
//! engine's check of a model's plan ([`plan`]). The Python SDK spec (#1341,
//! "Layout") says what else belongs here.
//! It holds the engine commit the build recorded (`build.rs`), the engine's
//! check of a model's plan ([`plan`]), and the credential lookup
//! ([`credentials`]). Both report an engine error as a [`refusal::Refusal`].
//! The Python SDK spec (#1341, "Layout") says what else belongs here.

pub mod credentials;
pub mod plan;
pub mod refusal;

// The resolver file below holds the EQL types only under this feature.
#[cfg(not(feature = "eql"))]
Expand Down
43 changes: 6 additions & 37 deletions languages/python/crates/core/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
//! engine sees the same declaration a Rust or Go caller would, and its
//! refusal reaches Python unchanged: its code, message, field and reason.

use miette::Diagnostic as _;
use serde::ser::{Serialize, SerializeMap, SerializeStruct, Serializer};
use stack_encrypt::dynamic::{self, record};
use stack_encrypt::ErrorPayload as _;

use crate::refusal::Refusal;
use crate::targets;

/// What one plan field stores.
Expand All @@ -22,20 +22,6 @@ pub enum Column<'a> {
Passthrough,
}

/// The engine's refusal of a plan, in the four fields every SDK error has.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Refusal {
/// The engine's diagnostic code, with `::` replaced by `_`:
/// `stack_encrypt_dynamic_plan`.
pub code: String,
/// The engine's message.
pub message: String,
/// The plan field at fault, where the engine names one.
pub field: Option<String>,
/// The engine's reason token, such as `no_fields`, where it gives one.
pub reason: Option<String>,
}

/// Check the plan of a model stored in `table`, with `fields` in
/// declaration order. Each field's context is `[table, field]`.
///
Expand All @@ -51,26 +37,18 @@ pub fn check(table: &str, fields: &[(&str, Column<'_>)]) -> Result<(), Refusal>
.map_err(|error| refusal(&error))
}

/// An engine diagnostic code as the SDK's `code`: `stack_encrypt::aead`
/// becomes `stack_encrypt_aead`.
pub fn python_code(engine_code: &str) -> String {
engine_code.replace("::", "_")
}

fn refusal(error: &dynamic::Error) -> Refusal {
let payload = error.payload();
let text = |key: &str| payload.get(key).and_then(|v| v.as_str()).map(str::to_owned);
Refusal {
code: error.code().map_or_else(
|| "stack_encrypt_dynamic".to_owned(),
|code| python_code(&code.to_string()),
),
message: error.to_string(),
field: text("field"),
field: payload
.get("field")
.and_then(|v| v.as_str())
.map(str::to_owned),
// Only the engine's own reason tokens. A resolver's reason, such as
// the catalog's for an unproducible type, is prose and stays in the
// message.
reason: error.reason().map(|reason| reason.as_str().to_owned()),
..Refusal::of(error)
}
}

Expand Down Expand Up @@ -149,13 +127,4 @@ mod tests {
let refused = check("users", &[("doc", Column::Eql("Json"))]).unwrap_err();
assert_eq!(refused.code, "stack_encrypt_target_unproducible");
}

#[test]
fn engine_codes_replace_the_path_separator() {
assert_eq!(python_code("stack_encrypt::aead"), "stack_encrypt_aead");
assert_eq!(
python_code("stack_kms::load_keyset_forbidden"),
"stack_kms_load_keyset_forbidden"
);
}
}
57 changes: 57 additions & 0 deletions languages/python/crates/core/src/refusal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! An engine error, in the four fields every SDK error has.

use miette::Diagnostic;

/// An engine error as the Python side raises it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Refusal {
/// The engine's diagnostic code, with `::` replaced by `_`:
/// `stack_encrypt_dynamic_plan`.
pub code: String,
/// The engine's message, with its help after it where it gives one. A
/// caller whose engine help names a Rust API writes its own message.
pub message: String,
/// The plan field at fault, where the engine names one.
pub field: Option<String>,
/// The engine's reason token, such as `no_fields`, where it gives one.
pub reason: Option<String>,
}

impl Refusal {
/// The code and message of `error`, with no field or reason.
pub fn of(error: &dyn Diagnostic) -> Self {
let message = match error.help() {
Some(help) => format!("{error} {help}"),
None => error.to_string(),
};
Self {
code: error.code().map_or_else(
|| "stack_unknown".to_owned(),
|code| python_code(&code.to_string()),
),
message,
field: None,
reason: None,
}
}
}

/// An engine diagnostic code as the SDK's `code`: `stack_encrypt::aead`
/// becomes `stack_encrypt_aead`.
pub fn python_code(engine_code: &str) -> String {
engine_code.replace("::", "_")
}

#[cfg(test)]
mod tests {
use super::python_code;

#[test]
fn engine_codes_replace_the_path_separator() {
assert_eq!(python_code("stack_encrypt::aead"), "stack_encrypt_aead");
assert_eq!(
python_code("stack_kms::load_keyset_forbidden"),
"stack_kms_load_keyset_forbidden"
);
}
}
34 changes: 27 additions & 7 deletions languages/python/crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@
//! the boundary. Logic belongs in `stackencrypt-core` and `stack-encrypt`.

use pyo3::prelude::*;
use stackencrypt_core::credentials;
use stackencrypt_core::plan::{self, Column};
use stackencrypt_core::refusal::Refusal;

/// A refusal as the tuple the Python side raises: `(code, message, field,
/// reason)`.
type RefusalTuple = (String, String, Option<String>, Option<String>);

fn to_tuple(refused: Refusal) -> RefusalTuple {
(refused.code, refused.message, refused.field, refused.reason)
}

/// The two build facts the package exposes: `(version, engine_commit)`.
///
Expand All @@ -27,20 +37,29 @@ fn build_info() -> (&'static str, &'static str) {
/// accepts the plan, or its refusal as `(code, message, field, reason)`. The
/// Python side raises it as a `DeclarationError`.
#[pyfunction]
fn check_plan(
table: &str,
fields: Vec<(String, Option<String>)>,
) -> Option<(String, String, Option<String>, Option<String>)> {
fn check_plan(table: &str, fields: Vec<(String, Option<String>)>) -> Option<RefusalTuple> {
let fields: Vec<(&str, Column<'_>)> = fields
.iter()
.map(|(name, eql_type)| {
let column = eql_type.as_deref().map_or(Column::Passthrough, Column::Eql);
(name.as_str(), column)
})
.collect();
plan::check(table, &fields)
.err()
.map(|refused| (refused.code, refused.message, refused.field, refused.reason))
plan::check(table, &fields).err().map(to_tuple)
}

/// Find the credentials as every SDK does: `CS_CLIENT_ACCESS_KEY` and
/// `CS_WORKSPACE_CRN`, then the profile store. Makes no request, and builds
/// no HTTP client. It reads the environment, and reads and parses the
/// profile's token file where there is one, which is short enough to run
/// with the GIL held. Returns `None` when the credentials are found, or the
/// refusal as `(code, message, field, reason)`.
///
/// Nothing keeps the strategy yet: `Cipher` only needs to know that the
/// credentials are there. The ticket that makes the first request keeps it.
#[pyfunction]
fn detect_credentials() -> Option<RefusalTuple> {
credentials::detect().err().map(to_tuple)
}

/// Gate fact 2 (#1384): block for `millis` on a multi-thread tokio runtime
Expand All @@ -67,6 +86,7 @@ fn _probe_detached_sleep(py: Python<'_>, millis: u64) -> u64 {
fn _engine(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(build_info, module)?)?;
module.add_function(wrap_pyfunction!(check_plan, module)?)?;
module.add_function(wrap_pyfunction!(detect_credentials, module)?)?;
#[cfg(feature = "gate-probe")]
module.add_function(wrap_pyfunction!(_probe_detached_sleep, module)?)?;
Ok(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,13 @@ Option 2.
The decorator writes `__repr__` and `__str__` on the class it
decorates. (`encrypt` returns plain mappings, by the spec, so there is
no encrypted type to write them on.)
Both print the passthrough fields and `[sealed]` in place of each
sealed field's value, the format of Go's generated `String` and
`LogValue`.
Both print the passthrough fields and `<sealed>` in place of each
sealed field's value.
That is the format of Go's generated `String` and `LogValue`, with
one change: Go prints `[sealed]`.
Python prints angle brackets because `[sealed]` is a valid tag in
rich's markup, and `rich.print(repr(user))` would show the field as
empty.
The same markers that drive encryption drive the print, so a sealed
field added later is hidden the day it is added; no per-field
`repr=False` is needed, and none is read.
Expand Down
8 changes: 6 additions & 2 deletions languages/python/python/stackencrypt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@

Walking skeleton. The package loads its native extension, ``_engine``, exposes
two facts about the build, exports the markers a model's fields are declared
with, and reads a model's declaration with ``model``. Nothing encrypts yet.
with, reads a model's declaration with ``model``, and builds a ``Cipher`` for
a declared class. Nothing encrypts yet.
"""

from stackencrypt import _engine, _eql
from stackencrypt._cipher import Cipher
from stackencrypt._declaration import model
# The markers for the EQL types the engine produces today. `_eql.__all__`
# lists them; a marker the engine cannot produce yet stays in `_eql` only.
from stackencrypt._eql import * # noqa: F403
from stackencrypt._errors import DeclarationError, StashError
from stackencrypt._errors import AuthenticationError, DeclarationError, StashError
from stackencrypt._markers import Ignore, Passthrough

# The distribution's version, and the commit of the engine it was built from.
Expand All @@ -19,6 +21,8 @@
__version__, __engine__ = _engine.build_info()

__all__ = [
"AuthenticationError",
"Cipher",
"DeclarationError",
"Ignore",
"Passthrough",
Expand Down
Loading
Loading