diff --git a/languages/python/Cargo.lock b/languages/python/Cargo.lock index 14a2a5550..08f59ff48 100644 --- a/languages/python/Cargo.lock +++ b/languages/python/Cargo.lock @@ -2307,6 +2307,7 @@ dependencies = [ "miette", "serde", "serde_json", + "stack-auth", "stack-encrypt", ] diff --git a/languages/python/Cargo.toml b/languages/python/Cargo.toml index 4aadcec9b..19c20c95e 100644 --- a/languages/python/Cargo.toml +++ b/languages/python/Cargo.toml @@ -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 } diff --git a/languages/python/crates/core/Cargo.toml b/languages/python/crates/core/Cargo.toml index d72622f5b..0bf3e0132 100644 --- a/languages/python/crates/core/Cargo.toml +++ b/languages/python/crates/core/Cargo.toml @@ -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] diff --git a/languages/python/crates/core/src/credentials.rs b/languages/python/crates/core/src/credentials.rs new file mode 100644 index 000000000..b17774ce3 --- /dev/null +++ b/languages/python/crates/core/src/credentials.rs @@ -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 { + 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..") + } + AuthError::InvalidCrn(_) => format!( + "{error}. CS_WORKSPACE_CRN has the form crn::, \ + 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 { + 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 {} diff --git a/languages/python/crates/core/src/lib.rs b/languages/python/crates/core/src/lib.rs index 12d8b34c9..f6437adb5 100644 --- a/languages/python/crates/core/src/lib.rs +++ b/languages/python/crates/core/src/lib.rs @@ -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"))] diff --git a/languages/python/crates/core/src/plan.rs b/languages/python/crates/core/src/plan.rs index cdfccf626..222f43c13 100644 --- a/languages/python/crates/core/src/plan.rs +++ b/languages/python/crates/core/src/plan.rs @@ -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. @@ -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, - /// The engine's reason token, such as `no_fields`, where it gives one. - pub reason: Option, -} - /// Check the plan of a model stored in `table`, with `fields` in /// declaration order. Each field's context is `[table, field]`. /// @@ -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) } } @@ -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" - ); - } } diff --git a/languages/python/crates/core/src/refusal.rs b/languages/python/crates/core/src/refusal.rs new file mode 100644 index 000000000..ed7b31fea --- /dev/null +++ b/languages/python/crates/core/src/refusal.rs @@ -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, + /// The engine's reason token, such as `no_fields`, where it gives one. + pub reason: Option, +} + +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" + ); + } +} diff --git a/languages/python/crates/engine/src/lib.rs b/languages/python/crates/engine/src/lib.rs index 89c8ca1d7..16d075db9 100644 --- a/languages/python/crates/engine/src/lib.rs +++ b/languages/python/crates/engine/src/lib.rs @@ -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, Option); + +fn to_tuple(refused: Refusal) -> RefusalTuple { + (refused.code, refused.message, refused.field, refused.reason) +} /// The two build facts the package exposes: `(version, engine_commit)`. /// @@ -27,10 +37,7 @@ 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)>, -) -> Option<(String, String, Option, Option)> { +fn check_plan(table: &str, fields: Vec<(String, Option)>) -> Option { let fields: Vec<(&str, Column<'_>)> = fields .iter() .map(|(name, eql_type)| { @@ -38,9 +45,21 @@ fn check_plan( (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 { + credentials::detect().err().map(to_tuple) } /// Gate fact 2 (#1384): block for `millis` on a multi-thread tokio runtime @@ -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(()) diff --git a/languages/python/docs/adr/0001-a-declaration-is-checked-when-the-class-is-defined.md b/languages/python/docs/adr/0001-a-declaration-is-checked-when-the-class-is-defined.md index 90ed802f0..90dc0100a 100644 --- a/languages/python/docs/adr/0001-a-declaration-is-checked-when-the-class-is-defined.md +++ b/languages/python/docs/adr/0001-a-declaration-is-checked-when-the-class-is-defined.md @@ -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 `` 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. diff --git a/languages/python/python/stackencrypt/__init__.py b/languages/python/python/stackencrypt/__init__.py index 1acd092e4..0369ef1de 100644 --- a/languages/python/python/stackencrypt/__init__.py +++ b/languages/python/python/stackencrypt/__init__.py @@ -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. @@ -19,6 +21,8 @@ __version__, __engine__ = _engine.build_info() __all__ = [ + "AuthenticationError", + "Cipher", "DeclarationError", "Ignore", "Passthrough", diff --git a/languages/python/python/stackencrypt/_cipher.py b/languages/python/python/stackencrypt/_cipher.py new file mode 100644 index 000000000..158595a5a --- /dev/null +++ b/languages/python/python/stackencrypt/_cipher.py @@ -0,0 +1,64 @@ +"""``Cipher``: encrypts and decrypts the instances of one declared class. + +Today it checks the class and the credentials, and stores the declaration. +``encrypt``, ``decrypt`` and ``query`` come in later tickets. +""" + +from stackencrypt import _engine +from stackencrypt._declaration import Declaration, check_unchanged, declaration_of +from stackencrypt._errors import AuthenticationError, DeclarationError + + +class Cipher: + """The cipher for one class declared with ``stackencrypt.model``:: + + users = stackencrypt.Cipher(User) + + The class must be declared itself: a subclass of a declared class is not. + The class must also be as it was when declared. The credentials come from + ``CS_CLIENT_ACCESS_KEY`` and ``CS_WORKSPACE_CRN``, or from the profile + that ``stash auth login`` writes. Building a cipher makes no request. + """ + + def __init__(self, cls: type) -> None: + # The type check comes first: declaration_of reads the class's own + # __dict__, and a string or an instance with __slots__ has none. + declaration = declaration_of(cls) if isinstance(cls, type) else None + if declaration is None: + raise _not_decorated(cls) + check_unchanged(cls, declaration) + refused = _engine.detect_credentials() + if refused is not None: + code, message, field, reason = refused + raise AuthenticationError(code, message, field=field, reason=reason) + self._cls = cls + self._declaration = declaration + + @property + def declaration(self) -> Declaration: + """What the decorator read from the class.""" + return self._declaration + + def __repr__(self) -> str: + return f"Cipher({self._cls.__qualname__})" + + +def _not_decorated(cls: object) -> DeclarationError: + if not isinstance(cls, type): + return DeclarationError( + "declaration_not_decorated", + f"Cipher takes a class declared with stackencrypt.model, not {cls!r}.", + ) + declared = next((b for b in cls.__mro__[1:] if declaration_of(b)), None) + if declared is not None: + detail = ( + f"It is a subclass of {declared.__name__!r}, and a subclass is not " + "declared by its base." + ) + else: + detail = "It has no stackencrypt.model decorator." + return DeclarationError( + "declaration_not_decorated", + f"class {cls.__name__!r} is not declared. {detail} Apply " + f'@stackencrypt.model("...") to {cls.__name__!r} itself.', + ) diff --git a/languages/python/python/stackencrypt/_declaration.py b/languages/python/python/stackencrypt/_declaration.py index 7dd01d57d..10d5b4d9d 100644 --- a/languages/python/python/stackencrypt/_declaration.py +++ b/languages/python/python/stackencrypt/_declaration.py @@ -22,6 +22,12 @@ class around ``Annotated`` is read by structure, so ``Mapped[Annotated[str, ``Ignore`` field. A decorator applied below ``@dataclass`` finds no such constructor, because the class has none yet. - The engine reads the plan, with the EQL types, as it does for every SDK. +- No hand-written ``__repr__``, ``__str__`` or ``__rich_repr__`` on the class + itself, unless ``redact=False``. Then the decorator replaces all three, so + that they show no plaintext. + +``Cipher`` checks the class again (``check_unchanged``): the hints, the +constructor and the replaced methods must be as they were at decoration. """ import dataclasses @@ -46,7 +52,7 @@ class around ``Annotated`` is read by structure, so ``Mapped[Annotated[str, get_type_hints, ) -from stackencrypt import _engine, _eql +from stackencrypt import _engine, _eql, _redact from stackencrypt._errors import DeclarationError from stackencrypt._markers import Ignore, Marker, Passthrough @@ -105,13 +111,28 @@ class _ReadField: @dataclass(frozen=True) class Declaration: """What the decorator read from a class: the table, and every field that - is a column, in field order. ``Ignore`` fields are not in it.""" + is a column, in field order. ``Ignore`` fields are not in it. + + ``hints``, ``constructor`` and ``methods`` are the class as it was at + decoration, for ``check_unchanged``. ``methods`` is the replaced + ``__repr__``, ``__str__`` and ``__rich_repr__``, or empty with + ``redact=False``. + """ table: str fields: Tuple[DeclaredField, ...] + hints: Dict[str, Any] = dataclasses.field(repr=False, compare=False) + constructor: Optional["_Constructor"] = dataclasses.field(repr=False, compare=False) + methods: Tuple[Callable[..., Any], ...] = dataclasses.field(repr=False, compare=False) + + +# A constructor's parameters: each one's name, kind and default object. The +# defaults are compared by identity (`is`), never with `==`: a default such +# as `math.nan` is not equal to itself, and an array's `==` is not a bool. +_Constructor = Tuple[Tuple[str, Any, Any], ...] -def model(table: str) -> Callable[[T], T]: +def model(table: str, *, redact: bool = True) -> Callable[[T], T]: """Declare a class as a model stored in ``table``. Apply it outermost, above ``@dataclass`` or ``@attrs.define``:: @@ -121,6 +142,10 @@ def model(table: str) -> Callable[[T], T]: class User: email: Annotated[str, TextEq] age: Annotated[int, Passthrough] + + ``repr`` and ``str`` of an instance then show ```` for each sealed + field. ``redact=False`` keeps the class's own ``__repr__`` and + ``__str__``, which may show plaintext. """ if not isinstance(table, str) or not table: raise TypeError('model takes the table name: @stackencrypt.model("users")') @@ -133,14 +158,27 @@ def decorate(cls: T) -> T: f"class {cls.__name__!r} is already declared, with table " f"{existing.table!r}. Apply stackencrypt.model once.", ) - read = _read_fields(cls) + hints = _hints(cls) + read = _read_fields(cls, hints) planned = [field for field in read if field.marker is not Ignore] for field in planned: _check_value_type(field) _check_constructor(cls, read) _check_plan(table, planned) - fields = tuple(DeclaredField(name=f.name, marker=f.marker) for f in planned) - setattr(cls, DECLARATION_ATTRIBUTE, Declaration(table=table, fields=fields)) + methods: Tuple[Callable[..., Any], ...] = () + if redact: + _check_no_hand_written_methods(cls) + methods = _redact.install( + cls, [(f.name, f.marker is Passthrough) for f in planned] + ) + declaration = Declaration( + table=table, + fields=tuple(DeclaredField(name=f.name, marker=f.marker) for f in planned), + hints=hints, + constructor=_constructor(cls), + methods=methods, + ) + setattr(cls, DECLARATION_ATTRIBUTE, declaration) return cls return decorate @@ -156,11 +194,96 @@ def declaration_of(cls: type) -> Optional[Declaration]: return found if isinstance(found, Declaration) else None -def _read_fields(cls: type) -> List[_ReadField]: +def check_unchanged(cls: type, declaration: Declaration) -> None: + """Refuse a class that changed after decoration, with + ``declaration_class_changed``. + + The replaced methods, the type hints and the constructor must be as they + were. A class is not checked again after ``Cipher`` is built. + """ + if declaration.methods: + replaced = [ + name + for name, installed in zip(_redact.METHODS, declaration.methods) + if cls.__dict__.get(name) is not installed + ] + if replaced: + raise _class_changed(cls, "methods", None, f"{', '.join(replaced)} replaced") try: - hints = get_type_hints(cls, include_extras=True) + hints = _hints(cls) + except DeclarationError as error: + where = f"of {error.field!r} " if error.field else "" + raise _class_changed( + cls, "fields", error.field, f"a type hint {where}no longer resolves ({error.__cause__})" + ) from error.__cause__ + changed = min( + ( + name + for name in set(hints) | set(declaration.hints) + if hints.get(name) != declaration.hints.get(name) + ), + default=None, + ) + if changed is not None: + raise _class_changed(cls, "fields", changed, f"the type hint of {changed!r} changed") + if not _same_constructor(_constructor(cls), declaration.constructor): + raise _class_changed(cls, "constructor", None, "its constructor changed") + + +def _class_changed( + cls: type, reason: str, field: Optional[str], what: str +) -> DeclarationError: + return DeclarationError( + "declaration_class_changed", + f"class {cls.__name__!r} changed after stackencrypt.model declared it: " + f"{what}. Apply every other decorator below stackencrypt.model, and " + "change no declared class after it is defined.", + field=field, + reason=reason, + ) + + +def _hints(cls: type) -> Dict[str, Any]: + try: + return get_type_hints(cls, include_extras=True) except (NameError, TypeError) as error: raise _hints_unresolved(cls, error) from error + + +def _signature(cls: type) -> Optional[inspect.Signature]: + try: + return inspect.signature(cls) + except (TypeError, ValueError): + return None + + +def _constructor(cls: type) -> Optional[_Constructor]: + signature = _signature(cls) + if signature is None: + return None + return tuple((p.name, p.kind, p.default) for p in signature.parameters.values()) + + +def _same_constructor(now: Optional[_Constructor], then: Optional[_Constructor]) -> bool: + if now is None or then is None: + return now is then + return len(now) == len(then) and all( + a[0] == b[0] and a[1] == b[1] and a[2] is b[2] for a, b in zip(now, then) + ) + + +def _check_no_hand_written_methods(cls: type) -> None: + for name in _redact.METHODS: + if _redact.hand_written(cls, name): + raise DeclarationError( + "declaration_repr_present", + f"class {cls.__name__!r} has a hand-written {name}, which may " + "show plaintext. Remove it, so that stackencrypt.model can " + "replace it, or pass redact=False to keep it.", + ) + + +def _read_fields(cls: type, hints: Dict[str, Any]) -> List[_ReadField]: names = _library_field_names(cls) from_hints = names is None if names is None: @@ -400,10 +523,8 @@ def _check_constructor(cls: type, read: List[_ReadField]) -> None: ``decrypt`` builds an instance with one keyword argument per plan field, and passes no ``Ignore`` field. """ - try: - parameters = inspect.signature(cls).parameters - except (TypeError, ValueError): - parameters = inspect.Signature().parameters + signature = _signature(cls) + parameters = (signature or inspect.Signature()).parameters any_keyword = any(p.kind is p.VAR_KEYWORD for p in parameters.values()) # A plain class with no constructor: a decorator below this one, such as # @dataclass, has not made the class yet. diff --git a/languages/python/python/stackencrypt/_engine.pyi b/languages/python/python/stackencrypt/_engine.pyi index 4ee2cbe2f..641c2ac5d 100644 --- a/languages/python/python/stackencrypt/_engine.pyi +++ b/languages/python/python/stackencrypt/_engine.pyi @@ -10,3 +10,7 @@ def check_plan( ) -> Optional[Tuple[str, str, Optional[str], Optional[str]]]: """The engine's check of a model's plan: ``None``, or its refusal as ``(code, message, field, reason)``.""" + +def detect_credentials() -> Optional[Tuple[str, str, Optional[str], Optional[str]]]: + """Find the credentials, with no request: ``None``, or the refusal as + ``(code, message, field, reason)``.""" diff --git a/languages/python/python/stackencrypt/_errors.py b/languages/python/python/stackencrypt/_errors.py index f308926da..076876ad3 100644 --- a/languages/python/python/stackencrypt/_errors.py +++ b/languages/python/python/stackencrypt/_errors.py @@ -36,3 +36,10 @@ def __str__(self) -> str: class DeclarationError(StashError): """A model class that cannot be declared, raised at its class statement.""" + + +class AuthenticationError(StashError): + """The credentials are missing, malformed or refused. + + The code is the engine's, such as ``stack_auth_not_authenticated``. + """ diff --git a/languages/python/python/stackencrypt/_redact.py b/languages/python/python/stackencrypt/_redact.py new file mode 100644 index 000000000..2c60ad0bb --- /dev/null +++ b/languages/python/python/stackencrypt/_redact.py @@ -0,0 +1,138 @@ +"""Redaction: ``repr`` and ``str`` of a declared class show no plaintext. + +The decorator replaces the class's ``__repr__``, ``__str__`` and +``__rich_repr__`` (the hook ``rich`` prints and pretty-prints through). Every +sealed field shows ````, a passthrough field shows its value, and an +``Ignore`` field is left out. Nothing else is covered: ``dataclasses.asdict``, +Pydantic's ``model_dump``, ``vars`` and ``__dict__`` still return plaintext, +and so does a debugger that reads attributes. + +A generated method (dataclass, attrs, Pydantic, ``NamedTuple``) is replaced. +A method written on the class itself is refused unless the decorator is +given ``redact=False``, because replacing it would silently drop the user's +code. A method inherited from a base is replaced on the declared class only. +""" + +import collections +import dataclasses +import inspect +import os +import reprlib +import types +from typing import Any, Callable, Iterator, Sequence, Tuple + + +class _Placeholder: + """What a repr shows in place of a value: ```` or ````.""" + + def __init__(self, text: str) -> None: + self._text = text + + def __repr__(self) -> str: + return self._text + + +# Angle brackets, not Go's `[sealed]`: that is a valid tag in rich's markup, +# so `rich.print(repr(user))` would show the field as empty. +SEALED = _Placeholder("") +UNSET = _Placeholder("") +UNAVAILABLE = _Placeholder("") + +# The descriptors that only read storage: a __slots__ member, and a +# NamedTuple field. Any other descriptor may do work, such as a SELECT. +_STORAGE_DESCRIPTORS = ( + types.MemberDescriptorType, + type(vars(collections.namedtuple("_Probe", "field"))["field"]), +) + +# The files a generated __repr__ reports as its own. A dataclass's __repr__ is +# wrapped for recursion, and the wrapper is in reprlib.py on 3.14 and in +# dataclasses.py on 3.9. attrs reports a "" name. +_GENERATOR_FILES = frozenset( + os.path.realpath(module.__file__) + for module in (dataclasses, reprlib) + if module.__file__ is not None +) + +# attrs compiles its methods under this file name. +_ATTRS_GENERATED = " bool: + """Whether ``cls`` itself has a ``name`` method written by hand. + + Only the class's own ``__dict__`` is read: a base's method is not the + class's. A generated method comes from a generator's file, or from + another module, such as ``pydantic.main``. A method compiled from a + string, as in the REPL, a notebook or ``python -c``, is hand-written. + """ + method = vars(cls).get(name) + code = getattr(method, "__code__", None) + if code is None or getattr(method, "__module__", None) != cls.__module__: + return False + filename = code.co_filename + return ( + not filename.startswith(_ATTRS_GENERATED) + and os.path.realpath(filename) not in _GENERATOR_FILES + ) + + +def install(cls: type, fields: Sequence[Tuple[str, bool]]) -> Tuple[Callable[..., Any], ...]: + """Replace ``__repr__``, ``__str__`` and ``__rich_repr__`` on ``cls``. + + ``fields`` is ``(name, in_clear)`` per plan field, in order. Returns the + three functions, so that ``Cipher`` can check that they are still in + place. + """ + shown = tuple(fields) + + def __repr__(self: object) -> str: + parts = ", ".join(f"{name}={value!r}" for name, value in __rich_repr__(self)) + return f"{type(self).__name__}({parts})" + + def __str__(self: object) -> str: + return __repr__(self) + + # rich reprs each value itself, so a passthrough value is yielded as it + # is, and a sealed one as the placeholder whose repr is . + def __rich_repr__(self: object) -> Iterator[Tuple[str, object]]: + for name, in_clear in shown: + yield name, (_value(self, name) if in_clear else SEALED) + + methods = (__repr__, __str__, __rich_repr__) + for method in methods: + method.__qualname__ = f"{cls.__qualname__}.{method.__name__}" + setattr(cls, method.__name__, method) + return methods + + +def _value(instance: object, name: str) -> object: + """A passthrough value, read without running a descriptor that may work. + + An attribute read can do work: a SQLAlchemy column that is not loaded runs + a SELECT, and a detached instance raises. So the value comes from the + instance's ``__dict__`` where it has one. Otherwise it is looked up on the + class with ``getattr_static``, with no descriptor run. A plain class + value, such as a default, is shown. A ``__slots__`` member or a + ``NamedTuple`` field is read, since it only reads storage. Any other + descriptor shows ````. + """ + state = getattr(instance, "__dict__", None) + if state is not None and name in state: + return state[name] + try: + found = inspect.getattr_static(instance, name) + except AttributeError: + return UNSET + if not hasattr(type(found), "__get__"): + return found + if isinstance(found, _STORAGE_DESCRIPTORS): + try: + return getattr(instance, name) + except AttributeError: + return UNSET + except Exception: # noqa: BLE001 A repr never raises. + return UNAVAILABLE + return UNSET diff --git a/languages/python/tests/test_cipher.py b/languages/python/tests/test_cipher.py new file mode 100644 index 000000000..656fca0bc --- /dev/null +++ b/languages/python/tests/test_cipher.py @@ -0,0 +1,223 @@ +"""``Cipher(cls)``: the class checks and the credentials. No request is made.""" + +import dataclasses +import math +from typing import Annotated + +import pytest + +import stackencrypt +from stackencrypt import ( + AuthenticationError, + Cipher, + DeclarationError, + Passthrough, + StashError, + TextEq, +) + +ACCESS_KEY = "CSAKtestKeyId.testKeySecret" +WORKSPACE_CRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + + +@pytest.fixture(autouse=True) +def no_credentials(monkeypatch, tmp_path): + """No credentials in the environment, and an empty profile store.""" + for name in ("CS_CLIENT_ACCESS_KEY", "CS_WORKSPACE_CRN", "CS_CLIENT_ID", "CS_CLIENT_KEY"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("CS_CONFIG_PATH", str(tmp_path / "no-profile")) + + +@pytest.fixture +def credentials(monkeypatch): + monkeypatch.setenv("CS_CLIENT_ACCESS_KEY", ACCESS_KEY) + monkeypatch.setenv("CS_WORKSPACE_CRN", WORKSPACE_CRN) + + +def declared(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + + return User + + +def refusal(build): + with pytest.raises(StashError) as raised: + build() + return raised.value + + +def test_a_cipher_is_built_for_a_declared_class(credentials): + User = declared() + + users = Cipher(User) + + assert users.declaration.table == "users" + assert [f.name for f in users.declaration.fields] == ["email", "age"] + + +def test_an_undecorated_class_is_refused(): + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, DeclarationError) + assert error.code == "declaration_not_decorated" + + +def test_a_subclass_of_a_declared_class_is_refused(): + User = declared() + + @dataclasses.dataclass + class Admin(User): + level: Annotated[int, Passthrough] = 0 + + error = refusal(lambda: Cipher(Admin)) + assert error.code == "declaration_not_decorated" + assert "subclass of 'User'" in str(error) + + +@pytest.mark.parametrize("value", ["users", None, 42]) +def test_a_value_that_is_not_a_class_is_refused(value): + error = refusal(lambda: Cipher(value)) + assert error.code == "declaration_not_decorated" + assert repr(value) in str(error) + + +def test_an_instance_is_refused(): + User = declared() + + error = refusal(lambda: Cipher(User(email="a", age=1))) + assert error.code == "declaration_not_decorated" + + +def test_a_slotted_instance_is_refused(): + class Slotted: + __slots__ = () + + assert refusal(lambda: Cipher(Slotted())).code == "declaration_not_decorated" + + +def test_a_default_that_is_not_equal_to_itself_is_still_unchanged(credentials): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + score: Annotated[float, Passthrough] = math.nan + + assert Cipher(User).declaration.table == "users" + + +def test_the_class_checks_come_before_the_credentials(): + # No credentials are set, and the class error is the one raised. + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + assert refusal(lambda: Cipher(User)).code == "declaration_not_decorated" + + +def test_an_annotation_added_after_decoration_is_refused(credentials): + User = declared() + User.__annotations__["nickname"] = Annotated[str, TextEq] + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "fields" + assert error.field == "nickname" + + +def test_a_replaced_repr_is_refused(credentials): + User = declared() + User.__repr__ = lambda self: self.email + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "methods" + assert "__repr__ replaced" in str(error) + + +def test_a_hint_that_no_longer_resolves_names_the_name(credentials): + User = declared() + User.__annotations__["posts"] = "List[Undefined]" + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "fields" + assert "Undefined" in str(error) or "List" in str(error) + + +def test_a_replaced_constructor_is_refused(credentials): + User = declared() + + def __init__(self, email): + self.email = email + + User.__init__ = __init__ + + error = refusal(lambda: Cipher(User)) + assert error.code == "declaration_class_changed" + assert error.reason == "constructor" + + +def test_no_credentials_is_an_authentication_error_from_the_engine(): + User = declared() + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, AuthenticationError) + assert error.code == "stack_auth_not_authenticated" + assert "CS_CLIENT_ACCESS_KEY" in error.message + assert "AutoStrategy" not in error.message + + +def test_a_bad_cts_host_names_the_variable(monkeypatch, credentials): + monkeypatch.setenv("CS_CTS_HOST", "not a url") + User = declared() + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, AuthenticationError) + assert "CS_CTS_HOST" in error.message + + +def test_a_corrupt_profile_names_the_remedy(monkeypatch, tmp_path): + # No access key, and a profile whose token file is not JSON. The layout + # is the profile store's: a current_workspace file, and the workspace's + # own directory with auth.json in it. + profile = tmp_path / "profile" + workspace = profile / "workspaces" / "ZVATKW3VHMFG27DY" + workspace.mkdir(parents=True) + (profile / "current_workspace").write_text("ZVATKW3VHMFG27DY") + (workspace / "auth.json").write_text("{not json") + monkeypatch.setenv("CS_CONFIG_PATH", str(profile)) + User = declared() + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, AuthenticationError) + assert error.code != "stack_auth_not_authenticated" + assert "CS_CONFIG_PATH" in error.message + assert "stash auth login" in error.message + + +def test_a_missing_workspace_crn_names_the_variable(monkeypatch): + monkeypatch.setenv("CS_CLIENT_ACCESS_KEY", ACCESS_KEY) + User = declared() + + error = refusal(lambda: Cipher(User)) + assert error.code == "stack_auth_missing_workspace_crn" + assert "CS_WORKSPACE_CRN" in error.message + assert "AutoStrategy" not in error.message + + +def test_a_malformed_access_key_is_an_authentication_error(monkeypatch): + monkeypatch.setenv("CS_CLIENT_ACCESS_KEY", "not-an-access-key") + monkeypatch.setenv("CS_WORKSPACE_CRN", WORKSPACE_CRN) + User = declared() + + error = refusal(lambda: Cipher(User)) + assert isinstance(error, AuthenticationError) + assert error.code.startswith("stack_auth_") + assert "not-an-access-key" not in str(error) diff --git a/languages/python/tests/test_redaction.py b/languages/python/tests/test_redaction.py new file mode 100644 index 000000000..c47cc67ac --- /dev/null +++ b/languages/python/tests/test_redaction.py @@ -0,0 +1,233 @@ +"""``repr`` and ``str`` of a declared class show no sealed value.""" + +import dataclasses +from typing import Annotated, NamedTuple + +import attrs +import pydantic +import pytest +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +import stackencrypt +from stackencrypt import DeclarationError, Ignore, Passthrough, TextEq +from stackencrypt._redact import SEALED + +SECRET = "alice@example.com" +EXPECTED = "User(email=, age=42)" + + +def declare_dataclass(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "not shown" + + return User(email=SECRET, age=42) + + +def declare_attrs(): + @stackencrypt.model("users") + @attrs.define + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "not shown" + + return User(email=SECRET, age=42) + + +def declare_pydantic(): + @stackencrypt.model("users") + class User(pydantic.BaseModel): + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "not shown" + + return User(email=SECRET, age=42) + + +class User(NamedTuple): + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + note: Annotated[str, Ignore] = "not shown" + + +stackencrypt.model("users")(User) + + +def declare_named_tuple(): + return User(email=SECRET, age=42) + + +class Base(DeclarativeBase): + pass + + +@stackencrypt.model("users") +class MappedUser(Base): + __tablename__ = "users" + email: Mapped[Annotated[str, TextEq]] = mapped_column(primary_key=True) + age: Mapped[Annotated[int, Passthrough]] + # No Ignore field: on SQLAlchemy, an Ignore field is a relationship, and a + # relationship under Annotated loads as one object, not a list. + + +def declare_sqlalchemy(): + return MappedUser(email=SECRET, age=42) + + +KINDS = [ + declare_dataclass, + declare_attrs, + declare_pydantic, + declare_named_tuple, + declare_sqlalchemy, +] + + +@pytest.mark.parametrize("declare", KINDS, ids=lambda d: d.__name__) +def test_repr_and_str_seal_every_encrypted_field(declare): + user = declare() + expected = EXPECTED.replace("User", type(user).__name__) + + for shown in (repr(user), str(user), f"{user}", f"{user!r}"): + assert shown == expected + assert SECRET not in shown + assert "note" not in shown + # The hook rich prints through, so rich.print and a rich traceback with + # locals show the same. rich reprs each value itself, so a passthrough + # value is the value. Checked without rich: the hook is a plain method. + assert list(user.__rich_repr__()) == [("email", SEALED), ("age", 42)] + + +def test_a_hand_written_repr_is_refused(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + def __repr__(self): + return f"User({self.email})" + + error = pytest.raises(DeclarationError, build).value + assert error.code == "declaration_repr_present" + assert "redact=False" in str(error) + + +class ReprBase: + """A base with its own __repr__, as a SQLAlchemy Base often has.""" + + def __repr__(self): + return "from the base" + + +def test_a_repr_inherited_from_a_base_is_replaced_not_refused(): + @stackencrypt.model("users") + @dataclasses.dataclass(repr=False) + class User(ReprBase): + email: Annotated[str, TextEq] + + assert repr(User(email=SECRET)) == "User(email=)" + assert repr(ReprBase()) == "from the base" + + +def test_a_repr_compiled_from_a_string_is_hand_written(): + # As in the REPL, a notebook or `python -c`: the code's file name is + # ``, and the method is still the user's. + source = ( + "@stackencrypt.model('users')\n" + "@dataclasses.dataclass\n" + "class User:\n" + " email: Annotated[str, TextEq]\n" + " def __repr__(self):\n" + " return self.email\n" + ) + namespace = dict(globals()) + + with pytest.raises(DeclarationError) as raised: + exec(compile(source, "", "exec"), namespace) + + assert raised.value.code == "declaration_repr_present" + + +def test_a_passthrough_value_is_read_without_running_a_descriptor(): + # A field that is not set is shown as unset, and no attribute read runs: + # a SQLAlchemy column that is not loaded would run a SELECT. + @stackencrypt.model("users") + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + + def __init__(self, **kwargs): + vars(self).update(kwargs) + + def __getattr__(self, name): + raise RuntimeError(f"{name} was read") + + assert repr(User(email=SECRET)) == "User(email=, age=)" + + +def test_a_class_level_default_is_shown(): + @stackencrypt.model("users") + class User: + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] = 7 + + def __init__(self, **kwargs): + vars(self).update(kwargs) + + assert repr(User(email=SECRET)) == "User(email=, age=7)" + assert repr(User(email=SECRET, age=8)) == "User(email=, age=8)" + + +def test_an_unset_slot_is_shown_as_unset(): + @stackencrypt.model("users") + class User: + __slots__ = ("email", "age") + email: Annotated[str, TextEq] + age: Annotated[int, Passthrough] + + def __init__(self, **kwargs): + for name, value in kwargs.items(): + setattr(self, name, value) + + assert repr(User(email=SECRET)) == "User(email=, age=)" + assert repr(User(email=SECRET, age=9)) == "User(email=, age=9)" + + +def test_a_hand_written_str_is_refused(): + def build(): + @stackencrypt.model("users") + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + def __str__(self): + return self.email + + assert pytest.raises(DeclarationError, build).value.code == "declaration_repr_present" + + +def test_redact_false_keeps_a_hand_written_repr_and_shows_plaintext(): + @stackencrypt.model("users", redact=False) + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + def __repr__(self): + return f"User({self.email})" + + # The escape hatch is visibly dangerous: the plaintext is in the repr. + assert SECRET in repr(User(email=SECRET)) + + +def test_redact_false_keeps_a_generated_repr_and_shows_plaintext(): + @stackencrypt.model("users", redact=False) + @dataclasses.dataclass + class User: + email: Annotated[str, TextEq] + + assert SECRET in repr(User(email=SECRET))