Passwords, tokens and other secrets travel through a configuration like any other value: they are set in a block, referenced from other blocks, passed into modules and published by outputs. Declaring them sensitive keeps them out of everything xcl and your application print, log or report, without changing how the configuration is written.
Declaring a sensitive field
Give the field the type types.Sensitive[T]. That is the only change; the
configuration sets it exactly as it would set a plain field. In Python the
field is annotated xcl.Sensitive[T]:
// Secret defines the block type `secret`, values that must not be seen. Data// is a types.Sensitive, so it is masked in output and events and encrypted in// state when the state has a key, and only Reveal returns the real values.//// As in Kubernetes, nothing else holds a secret's values: a container reads// one through a secret_key_ref naming the secret and the key, which are plain// strings, so the deployment never becomes sensitive itself.type Secret struct { Meta types.Meta
Data types.Sensitive[map[string]string] `xcl:"data" json:"data"`}from dataclasses import dataclass
import xcl
# Secret defines the block type `secret`, values that must not be seen. data# is an xcl.Sensitive, so it is masked in output and events and encrypted in# state when the state has a key.## As in Kubernetes, nothing else holds a secret's values: a container reads# one through a secret_key_ref naming the secret and the key, which are plain# strings, so the deployment never becomes sensitive itself.@dataclassclass Secret: data: xcl.Sensitive[dict[str, str]] meta: xcl.Meta | None = NoneHere T is a map, so every value the secret holds is sensitive:
secret "db" { data = { password = env("DB_PASSWORD") }}The same declaration works on the types a plugin provides. Plugin types may use
types.Sensitive[string], [int], [int64], [float64], [bool],
[[]string] and [map[string]string]; a plugin type using another fails to
load, naming the type and the field. In tests, build one with
types.NewSensitive("hunter2").
Unwrapping in application code
.Reveal() is the only way to the real value. Call it where the value is
used, such as when building a connection string, and never print or log what
it returns: once revealed, a value is an ordinary value and is no longer
protected.
// databaseURL builds the connection string for the database. It holds the real// password, read with Reveal, so it must not be printed or logged.func databaseURL(secret *resources.Secret) string { connection := url.URL{ Scheme: "postgres", User: url.UserPassword("app", secret.Data.Reveal()["password"]), Host: "postgres.default.svc:5432", Path: "/app", }
return connection.String()}# A Sensitive read back through a lookup holds no value: it prints as the# marker, and reveal() returns None.found = cfg.find(Secret, "secret.db")
print(found.data) # (sensitive)print(found.data.reveal()) # Noneprint(found.data.is_redacted) # TrueIn Python, reveal() on a looked-up value returns None, because real values never leave the engine through a lookup; a sensitive value is only revealed on the Go side, or read from state. Only an xcl.Sensitive the program constructed itself, such as xcl.Sensitive("hunter2"), reveals its value.
A plugin’s provider does the same, calling Reveal() only where it connects.
It must not log the revealed value, put it in an error, or copy it into a field
that is not sensitive. Logging the sensitive field itself is safe, since it
writes (sensitive).
Sensitivity follows the value
A value derived from a sensitive value is sensitive too: one referenced from a
sensitive field, interpolated into a string, passed through a function, passed
into a module input, or published by an output. An output holds its sensitive
parts as types.Sensitive values, so an output whose value is a whole database
block reads back as a map whose password entry is a
types.Sensitive[string], before and after a reload.
When a sensitive value meets a plain field
A sensitive value can only be assigned to a field declared sensitive. In
configuration, assigning one to a plain field fails validation, so Validate
and Apply refuse the configuration before anything is created:
field "note" of audit.main is not declared sensitive and cannot beassigned a sensitive valueIn Go, converting or looking up an entity into a type whose matching field is
plain fails with xcl.ErrTypeMismatch. The *xcl.TypeMismatchError detail
names the field. There is no automatic unwrap: declare the field
types.Sensitive[T] in your own type too.
_, err := xcl.Find[PlainDatabase](c, "database.postgres.main")// entity "database.postgres.main" field "password" is sensitive,// main.PlainDatabase declares it as a plain value@dataclassclass PlainDatabase: location: str password: str # plain, so a sensitive value cannot be assigned to it meta: xcl.Meta | None = None
local.register_type(PlainDatabase, "database", "postgres")
try: cfg.validate("./config")except xcl.ConfigError as err: for problem in err.problems: print(problem.file, problem.line, problem.message)In Python the registered class declares the field, so the mismatch is caught at validation: assigning a sensitive value to the plain password raises xcl.ConfigError, whose problems carry the file and line of the assignment.
Your own JSON and templates show the marker
A sensitive value formats as (sensitive) everywhere Go formats it: every
fmt verb, log/slog, encoding/json and text/template. Marshalling the
secret above with json.MarshalIndent writes the marker in place of its data:
"data": "(sensitive)"To put the real value in your own output, unwrap it with .Reveal() first,
knowing that it is then unprotected.
What xcl shows, and what it keeps
- Logs, events and errors show only
(sensitive). Event data carries the marker at both data levels, and a function that fails on a sensitive argument shows the marker in place of the value in its error. - State keeps the real value, so a configuration that loads it reads the value back unchanged. The state file therefore holds the secret in plain text.
- Configuration text from
EncodeEntityandEncodeSavedEntitywritespassword = "(sensitive)". Passxcl.RevealSensitive()to write the real value. - The entity printer,
logger.ResourcePrinter, shows the marker in every format.logger.WithRevealSensitive(true)shows real values. - Diffs from
Config.Diffreport that a sensitive value changed without either value, and its rendered text,Diff.Pieces, shows(sensitive value). Passdiff.RevealSensitive()to include both values. See Diffs.
A value read back from event data has no real value to show, so it is written as the marker even when revealing is asked for.
State keeps the real value, in plain text unless you give xcl a key: see State masking to encrypt it.