From 9ae6724e223e4d71f40226b52ed8c7e59b709dc6 Mon Sep 17 00:00:00 2001 From: Yuji Yokoo Date: Thu, 29 Oct 2026 18:16:21 +0900 Subject: [PATCH 1/3] feat(python): redact declared classes and build a Cipher (#1388) repr and str show [sealed] for sealed fields. Cipher(cls) checks the class again and finds the credentials, with no request and no HTTP client. Co-Authored-By: Claude Fable 5.1 --- languages/python/Cargo.lock | 1 + languages/python/Cargo.toml | 11 +- languages/python/crates/core/Cargo.toml | 1 + .../python/crates/core/src/credentials.rs | 50 ++++++ languages/python/crates/core/src/lib.rs | 9 +- languages/python/crates/core/src/plan.rs | 43 +---- languages/python/crates/core/src/refusal.rs | 56 +++++++ languages/python/crates/engine/src/lib.rs | 32 +++- .../python/python/stackencrypt/__init__.py | 8 +- .../python/python/stackencrypt/_cipher.py | 62 ++++++++ .../python/stackencrypt/_declaration.py | 118 ++++++++++++-- .../python/python/stackencrypt/_engine.pyi | 4 + .../python/python/stackencrypt/_errors.py | 7 + .../python/python/stackencrypt/_redact.py | 86 ++++++++++ languages/python/tests/test_cipher.py | 148 ++++++++++++++++++ languages/python/tests/test_redaction.py | 147 +++++++++++++++++ 16 files changed, 719 insertions(+), 64 deletions(-) create mode 100644 languages/python/crates/core/src/credentials.rs create mode 100644 languages/python/crates/core/src/refusal.rs create mode 100644 languages/python/python/stackencrypt/_cipher.py create mode 100644 languages/python/python/stackencrypt/_redact.py create mode 100644 languages/python/tests/test_cipher.py create mode 100644 languages/python/tests/test_redaction.py 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..5e2ff843d --- /dev/null +++ b/languages/python/crates/core/src/credentials.rs @@ -0,0 +1,50 @@ +//! 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`). 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::{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 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. +/// +/// # Errors +/// +/// The engine's refusal, such as `stack_auth_not_authenticated` when there +/// are no credentials. +pub fn detect() -> Result<(), Refusal> { + AutoStrategy::builder() + .transport(NoRequest) + .detect() + .map(drop) + .map_err(|error| Refusal::of(&error)) +} + +/// 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..5f67fe523 --- /dev/null +++ b/languages/python/crates/core/src/refusal.rs @@ -0,0 +1,56 @@ +//! 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. + 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..9331c5940 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,19 @@ 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. Returns +/// `None` when they 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 +84,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/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..80529ec64 --- /dev/null +++ b/languages/python/python/stackencrypt/_cipher.py @@ -0,0 +1,62 @@ +"""``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: + declaration = declaration_of(cls) + 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..d1fe267c6 100644 --- a/languages/python/python/stackencrypt/_declaration.py +++ b/languages/python/python/stackencrypt/_declaration.py @@ -22,6 +22,11 @@ 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__`` or ``__str__``, unless ``redact=False``. Then + the decorator replaces both, 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 +51,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 +110,23 @@ 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``, ``signature`` and ``methods`` are the class as it was at + decoration, for ``check_unchanged``. ``methods`` is the replaced + ``__repr__`` and ``__str__``, or empty with ``redact=False``. + """ table: str fields: Tuple[DeclaredField, ...] + hints: Dict[str, Any] = dataclasses.field(repr=False, compare=False) + signature: Optional[inspect.Signature] = dataclasses.field(repr=False, compare=False) + methods: Tuple[Callable[[object], str], ...] = dataclasses.field( + repr=False, compare=False + ) -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 +136,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 ``[sealed]`` 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 +152,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[[object], str], ...] = () + 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, + signature=_signature(cls), + methods=methods, + ) + setattr(cls, DECLARATION_ATTRIBUTE, declaration) return cls return decorate @@ -156,11 +188,77 @@ 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 and tuple( + cls.__dict__.get(name) for name in _redact.METHODS + ) != declaration.methods: + raise _class_changed( + cls, "methods", None, "its __repr__ or __str__ was replaced" + ) try: hints = get_type_hints(cls, include_extras=True) + except (NameError, TypeError) as error: + raise _class_changed( + cls, "fields", None, f"a type hint no longer resolves ({error})" + ) from error + if hints != declaration.hints: + changed = sorted( + name + for name in set(hints) | set(declaration.hints) + if hints.get(name) != declaration.hints.get(name) + ) + raise _class_changed( + cls, "fields", changed[0], f"the type hint of {changed[0]!r} changed" + ) + if _signature(cls) != declaration.signature: + 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 _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 +498,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..76df52219 --- /dev/null +++ b/languages/python/python/stackencrypt/_redact.py @@ -0,0 +1,86 @@ +"""Redaction: ``repr`` and ``str`` of a declared class show no plaintext. + +The decorator replaces the class's ``__repr__`` and ``__str__``. Every sealed +field shows ``[sealed]``, a passthrough field shows its value, and an +``Ignore`` field is left out. Nothing else is covered: ``dataclasses.asdict``, +Pydantic's ``model_dump`` and ``vars`` still return plaintext. + +A generated ``__repr__`` (dataclass, attrs, Pydantic, ``NamedTuple``) is +replaced. A hand-written one is refused unless the decorator is given +``redact=False``, because replacing it would silently drop the user's code. +""" + +import dataclasses +import os +import reprlib +from typing import Callable, Sequence, Tuple + +SEALED = "[sealed]" + +# 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 +) + +METHODS = ("__repr__", "__str__") + + +def hand_written(cls: type, name: str) -> bool: + """Whether the first ``name`` method on ``cls`` was written by hand in the + class's own module. + + A generated method comes from a generator's file or a ``<...>`` name, or + belongs to another module, such as ``pydantic.main``. ``object``'s own + method is not hand-written. + """ + for base in cls.__mro__: + if base is object: + return False + if name in vars(base): + method = vars(base)[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("<") + and os.path.realpath(filename) not in _GENERATOR_FILES + ) + return False + + +def install( + cls: type, fields: Sequence[Tuple[str, bool]] +) -> Tuple[Callable[[object], str], Callable[[object], str]]: + """Replace ``__repr__`` and ``__str__`` on ``cls``. + + ``fields`` is ``(name, in_clear)`` per plan field, in order. Returns the two + 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(self, name) if in_clear else SEALED}" + for name, in_clear in shown + ) + return f"{type(self).__name__}({parts})" + + def __str__(self: object) -> str: + return __repr__(self) + + for method in (__repr__, __str__): + method.__qualname__ = f"{cls.__qualname__}.{method.__name__}" + setattr(cls, method.__name__, method) + return __repr__, __str__ + + +def _value(instance: object, name: str) -> str: + try: + return repr(getattr(instance, name)) + except AttributeError: + return "" diff --git a/languages/python/tests/test_cipher.py b/languages/python/tests/test_cipher.py new file mode 100644 index 000000000..2b0be7e48 --- /dev/null +++ b/languages/python/tests/test_cipher.py @@ -0,0 +1,148 @@ +"""``Cipher(cls)``: the class checks and the credentials. No request is made.""" + +import dataclasses +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) + + +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_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" + + +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 + + +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..c01247c6b --- /dev/null +++ b/languages/python/tests/test_redaction.py @@ -0,0 +1,147 @@ +"""``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 + +SECRET = "alice@example.com" +EXPECTED = "User(email=[sealed], 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 + + +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) + + +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)) From 408d03c5feb85e416df825169c5ef2b09140ea39 Mon Sep 17 00:00:00 2001 From: Yuji Yokoo Date: Thu, 29 Oct 2026 19:45:50 +0900 Subject: [PATCH 2/3] fix(python): address the reviews of redaction and Cipher (#1388) Credentials are found without building an HTTP client, and each refusal names the variable or file to fix. Redaction reads the class's own dict, covers rich, and runs no descriptor that may do work. Co-Authored-By: Claude Fable 5.1 --- .../python/crates/core/src/credentials.rs | 57 ++++++- languages/python/crates/core/src/refusal.rs | 3 +- languages/python/crates/engine/src/lib.rs | 8 +- .../python/python/stackencrypt/_cipher.py | 4 +- .../python/stackencrypt/_declaration.py | 79 ++++++---- .../python/python/stackencrypt/_redact.py | 142 ++++++++++++------ languages/python/tests/test_cipher.py | 75 +++++++++ languages/python/tests/test_redaction.py | 86 +++++++++++ 8 files changed, 368 insertions(+), 86 deletions(-) diff --git a/languages/python/crates/core/src/credentials.rs b/languages/python/crates/core/src/credentials.rs index 5e2ff843d..b17774ce3 100644 --- a/languages/python/crates/core/src/credentials.rs +++ b/languages/python/crates/core/src/credentials.rs @@ -2,31 +2,72 @@ //! //! `stack_auth::AutoStrategy` reads `CS_CLIENT_ACCESS_KEY` and //! `CS_WORKSPACE_CRN`, then the profile store (`~/.cipherstash`, or -//! `CS_CONFIG_PATH`). It makes no request. The Python tests cover it: a -//! Rust test would set environment variables that other tests in the same +//! `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::{AutoStrategy, HttpRequest, HttpResponse, HttpTransport, RequestError}; +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 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. +/// 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. +/// 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| Refusal::of(&error)) + .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. diff --git a/languages/python/crates/core/src/refusal.rs b/languages/python/crates/core/src/refusal.rs index 5f67fe523..ed7b31fea 100644 --- a/languages/python/crates/core/src/refusal.rs +++ b/languages/python/crates/core/src/refusal.rs @@ -8,7 +8,8 @@ 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. + /// 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, diff --git a/languages/python/crates/engine/src/lib.rs b/languages/python/crates/engine/src/lib.rs index 9331c5940..16d075db9 100644 --- a/languages/python/crates/engine/src/lib.rs +++ b/languages/python/crates/engine/src/lib.rs @@ -49,9 +49,11 @@ fn check_plan(table: &str, fields: Vec<(String, Option)>) -> Option None: - declaration = declaration_of(cls) + # 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) diff --git a/languages/python/python/stackencrypt/_declaration.py b/languages/python/python/stackencrypt/_declaration.py index d1fe267c6..5ec0df649 100644 --- a/languages/python/python/stackencrypt/_declaration.py +++ b/languages/python/python/stackencrypt/_declaration.py @@ -22,8 +22,9 @@ 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__`` or ``__str__``, unless ``redact=False``. Then - the decorator replaces both, so that they show no plaintext. +- 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. @@ -112,18 +113,23 @@ 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. - ``hints``, ``signature`` and ``methods`` are the class as it was at + ``hints``, ``constructor`` and ``methods`` are the class as it was at decoration, for ``check_unchanged``. ``methods`` is the replaced - ``__repr__`` and ``__str__``, or empty with ``redact=False``. + ``__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) - signature: Optional[inspect.Signature] = dataclasses.field(repr=False, compare=False) - methods: Tuple[Callable[[object], str], ...] = 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, *, redact: bool = True) -> Callable[[T], T]: @@ -159,7 +165,7 @@ def decorate(cls: T) -> T: _check_value_type(field) _check_constructor(cls, read) _check_plan(table, planned) - methods: Tuple[Callable[[object], str], ...] = () + methods: Tuple[Callable[..., Any], ...] = () if redact: _check_no_hand_written_methods(cls) methods = _redact.install( @@ -169,7 +175,7 @@ def decorate(cls: T) -> T: table=table, fields=tuple(DeclaredField(name=f.name, marker=f.marker) for f in planned), hints=hints, - signature=_signature(cls), + constructor=_constructor(cls), methods=methods, ) setattr(cls, DECLARATION_ATTRIBUTE, declaration) @@ -195,28 +201,32 @@ def check_unchanged(cls: type, declaration: Declaration) -> None: 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 and tuple( - cls.__dict__.get(name) for name in _redact.METHODS - ) != declaration.methods: - raise _class_changed( - cls, "methods", None, "its __repr__ or __str__ was replaced" - ) + 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) - except (NameError, TypeError) as error: + hints = _hints(cls) + except DeclarationError as error: + where = f"of {error.field!r} " if error.field else "" raise _class_changed( - cls, "fields", None, f"a type hint no longer resolves ({error})" - ) from error - if hints != declaration.hints: - changed = sorted( + 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) - ) - raise _class_changed( - cls, "fields", changed[0], f"the type hint of {changed[0]!r} changed" - ) - if _signature(cls) != declaration.signature: + ), + 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") @@ -247,6 +257,21 @@ def _signature(cls: type) -> Optional[inspect.Signature]: 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): diff --git a/languages/python/python/stackencrypt/_redact.py b/languages/python/python/stackencrypt/_redact.py index 76df52219..dce573b7b 100644 --- a/languages/python/python/stackencrypt/_redact.py +++ b/languages/python/python/stackencrypt/_redact.py @@ -1,21 +1,47 @@ """Redaction: ``repr`` and ``str`` of a declared class show no plaintext. -The decorator replaces the class's ``__repr__`` and ``__str__``. Every sealed -field shows ``[sealed]``, a passthrough field shows its value, and an +The decorator replaces the class's ``__repr__``, ``__str__`` and +``__rich_repr__`` (the hook ``rich`` prints and pretty-prints through). Every +sealed field shows ``[sealed]``, a passthrough field shows its value, and an ``Ignore`` field is left out. Nothing else is covered: ``dataclasses.asdict``, -Pydantic's ``model_dump`` and ``vars`` still return plaintext. +Pydantic's ``model_dump``, ``vars`` and ``__dict__`` still return plaintext, +and so does a debugger that reads attributes. -A generated ``__repr__`` (dataclass, attrs, Pydantic, ``NamedTuple``) is -replaced. A hand-written one is refused unless the decorator is given -``redact=False``, because replacing it would silently drop the user's code. +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 -from typing import Callable, Sequence, Tuple +import types +from typing import Any, Callable, Iterator, Sequence, Tuple -SEALED = "[sealed]" + +class _Placeholder: + """What a repr shows in place of a value: ``[sealed]`` or ````.""" + + def __init__(self, text: str) -> None: + self._text = text + + def __repr__(self) -> str: + return self._text + + +SEALED = _Placeholder("[sealed]") +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 @@ -26,61 +52,85 @@ if module.__file__ is not None ) -METHODS = ("__repr__", "__str__") +# attrs compiles its methods under this file name. +_ATTRS_GENERATED = " bool: - """Whether the first ``name`` method on ``cls`` was written by hand in the - class's own module. + """Whether ``cls`` itself has a ``name`` method written by hand. - A generated method comes from a generator's file or a ``<...>`` name, or - belongs to another module, such as ``pydantic.main``. ``object``'s own - method is not hand-written. + 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. """ - for base in cls.__mro__: - if base is object: - return False - if name in vars(base): - method = vars(base)[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("<") - and os.path.realpath(filename) not in _GENERATOR_FILES - ) - return False - - -def install( - cls: type, fields: Sequence[Tuple[str, bool]] -) -> Tuple[Callable[[object], str], Callable[[object], str]]: - """Replace ``__repr__`` and ``__str__`` on ``cls``. - - ``fields`` is ``(name, in_clear)`` per plan field, in order. Returns the two - functions, so that ``Cipher`` can check that they are still in place. + 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(self, name) if in_clear else SEALED}" - for name, in_clear in shown - ) + 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) - for method in (__repr__, __str__): + # rich reprs each value itself, so a passthrough value is yielded as it + # is, and a sealed one as the placeholder whose repr is [sealed]. + 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 __repr__, __str__ + return methods -def _value(instance: object, name: str) -> str: +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: - return repr(getattr(instance, name)) + found = inspect.getattr_static(instance, name) except AttributeError: - return "" + 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 index 2b0be7e48..656fca0bc 100644 --- a/languages/python/tests/test_cipher.py +++ b/languages/python/tests/test_cipher.py @@ -1,6 +1,7 @@ """``Cipher(cls)``: the class checks and the credentials. No request is made.""" import dataclasses +import math from typing import Annotated import pytest @@ -80,6 +81,13 @@ class Admin(User): 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() @@ -87,6 +95,23 @@ def test_an_instance_is_refused(): 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 @@ -113,6 +138,17 @@ def test_a_replaced_repr_is_refused(credentials): 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): @@ -135,6 +171,45 @@ def test_no_credentials_is_an_authentication_error_from_the_engine(): 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): diff --git a/languages/python/tests/test_redaction.py b/languages/python/tests/test_redaction.py index c01247c6b..ebd4abdd5 100644 --- a/languages/python/tests/test_redaction.py +++ b/languages/python/tests/test_redaction.py @@ -10,6 +10,7 @@ import stackencrypt from stackencrypt import DeclarationError, Ignore, Passthrough, TextEq +from stackencrypt._redact import SEALED SECRET = "alice@example.com" EXPECTED = "User(email=[sealed], age=42)" @@ -95,6 +96,10 @@ def test_repr_and_str_seal_every_encrypted_field(declare): 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(): @@ -112,6 +117,87 @@ def __repr__(self): 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=[sealed])" + 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=[sealed], 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=[sealed], age=7)" + assert repr(User(email=SECRET, age=8)) == "User(email=[sealed], 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=[sealed], age=)" + assert repr(User(email=SECRET, age=9)) == "User(email=[sealed], age=9)" + + def test_a_hand_written_str_is_refused(): def build(): @stackencrypt.model("users") From 5b792d5392989b6889af27be45b189e36fbf4389 Mon Sep 17 00:00:00 2001 From: Yuji Yokoo Date: Fri, 30 Oct 2026 17:59:42 +0900 Subject: [PATCH 3/3] feat(python): redact declared classes and build a Cipher (#1388) repr and str show [sealed] for sealed fields. Cipher(cls) checks the class again and finds the credentials, with no request. Co-Authored-By: Claude Fable 5.1 --- ...aration-is-checked-when-the-class-is-defined.md | 10 +++++++--- .../python/python/stackencrypt/_declaration.py | 2 +- languages/python/python/stackencrypt/_redact.py | 10 ++++++---- languages/python/tests/test_redaction.py | 14 +++++++------- 4 files changed, 21 insertions(+), 15 deletions(-) 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/_declaration.py b/languages/python/python/stackencrypt/_declaration.py index 5ec0df649..10d5b4d9d 100644 --- a/languages/python/python/stackencrypt/_declaration.py +++ b/languages/python/python/stackencrypt/_declaration.py @@ -143,7 +143,7 @@ class User: email: Annotated[str, TextEq] age: Annotated[int, Passthrough] - ``repr`` and ``str`` of an instance then show ``[sealed]`` for each sealed + ``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. """ diff --git a/languages/python/python/stackencrypt/_redact.py b/languages/python/python/stackencrypt/_redact.py index dce573b7b..2c60ad0bb 100644 --- a/languages/python/python/stackencrypt/_redact.py +++ b/languages/python/python/stackencrypt/_redact.py @@ -2,7 +2,7 @@ The decorator replaces the class's ``__repr__``, ``__str__`` and ``__rich_repr__`` (the hook ``rich`` prints and pretty-prints through). Every -sealed field shows ``[sealed]``, a passthrough field shows its value, and an +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. @@ -23,7 +23,7 @@ class _Placeholder: - """What a repr shows in place of a value: ``[sealed]`` or ````.""" + """What a repr shows in place of a value: ```` or ````.""" def __init__(self, text: str) -> None: self._text = text @@ -32,7 +32,9 @@ def __repr__(self) -> str: return self._text -SEALED = _Placeholder("[sealed]") +# 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("") @@ -94,7 +96,7 @@ 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 [sealed]. + # 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) diff --git a/languages/python/tests/test_redaction.py b/languages/python/tests/test_redaction.py index ebd4abdd5..c47cc67ac 100644 --- a/languages/python/tests/test_redaction.py +++ b/languages/python/tests/test_redaction.py @@ -13,7 +13,7 @@ from stackencrypt._redact import SEALED SECRET = "alice@example.com" -EXPECTED = "User(email=[sealed], age=42)" +EXPECTED = "User(email=, age=42)" def declare_dataclass(): @@ -130,7 +130,7 @@ def test_a_repr_inherited_from_a_base_is_replaced_not_refused(): class User(ReprBase): email: Annotated[str, TextEq] - assert repr(User(email=SECRET)) == "User(email=[sealed])" + assert repr(User(email=SECRET)) == "User(email=)" assert repr(ReprBase()) == "from the base" @@ -167,7 +167,7 @@ def __init__(self, **kwargs): def __getattr__(self, name): raise RuntimeError(f"{name} was read") - assert repr(User(email=SECRET)) == "User(email=[sealed], age=)" + assert repr(User(email=SECRET)) == "User(email=, age=)" def test_a_class_level_default_is_shown(): @@ -179,8 +179,8 @@ class User: def __init__(self, **kwargs): vars(self).update(kwargs) - assert repr(User(email=SECRET)) == "User(email=[sealed], age=7)" - assert repr(User(email=SECRET, age=8)) == "User(email=[sealed], age=8)" + 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(): @@ -194,8 +194,8 @@ def __init__(self, **kwargs): for name, value in kwargs.items(): setattr(self, name, value) - assert repr(User(email=SECRET)) == "User(email=[sealed], age=)" - assert repr(User(email=SECRET, age=9)) == "User(email=[sealed], age=9)" + 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():