SeliseBlocks.Secrets.OS
4.0.3
dotnet add package SeliseBlocks.Secrets.OS --version 4.0.3
NuGet\Install-Package SeliseBlocks.Secrets.OS -Version 4.0.3
<PackageReference Include="SeliseBlocks.Secrets.OS" Version="4.0.3" />
<PackageVersion Include="SeliseBlocks.Secrets.OS" Version="4.0.3" />
<PackageReference Include="SeliseBlocks.Secrets.OS" />
paket add SeliseBlocks.Secrets.OS --version 4.0.3
#r "nuget: SeliseBlocks.Secrets.OS, 4.0.3"
#:package SeliseBlocks.Secrets.OS@4.0.3
#addin nuget:?package=SeliseBlocks.Secrets.OS&version=4.0.3
#tool nuget:?package=SeliseBlocks.Secrets.OS&version=4.0.3
SeliseBlocks.Secrets.OS
Where the store lives
Metadata and audit live in a SecretStore database on the calling tenant's own Mongo
connection — the DbConnectionString recorded in the root registry when the environment was
provisioned. A dev environment's secrets are on the dev cluster, a staging environment's on the
other cluster, prod and root on main. Tenants sharing a connection share one SecretStore, and
the mandatory TenantId filter on every query is what separates them.
Nothing needs migrating to adopt this. An existing tenant's registry record already names the main connection, so it resolves to exactly the database it used before.
There is no fallback to main. A request whose tenant is missing from the registry, disabled, or recorded without a connection fails rather than reading somewhere else — reading main for a tenant placed elsewhere would return an empty list rather than an error, and a write would put a credential on a cluster nobody expects it on.
Values are the exception when Key Vault is in use: see below.
Configuration
Values live in Azure Key Vault, or — in an environment with no vault provisioned yet — in
the same SecretStore database as the metadata.
| Variable | Required | Purpose |
|---|---|---|
Secrets__ValueStore |
no | KeyVault or Database. Overrides the inference below. |
With nothing set, the store is inferred: KeyVault when KeyVault__KeyVaultUrl is present,
Database when it is not. That way an environment with a vault keeps using it and the values
already in it stay readable, while an environment without one gets a working store instead of a
startup failure.
The two stores keep their bytes in different places, so a value written to one is invisible to the other. Switching an environment that already holds values means re-entering them, not just setting a variable.
Database stores values unencrypted, in a SecretValues collection of their own so a metadata
read can never carry one out with it. It keeps the whole ISecretService contract around them —
tenant-scoped authorization, rotation, soft delete, audit — and keeps a value out of the
configuration document that owns it and out of every configuration API response. What it does not
do is protect a value from anyone who can read the SecretStore database. It is the interim
option; KeyVault is the intended production store.
Key Vault
Configured through the KeyVault environment section — the same keys Blocks.Genesis reads, so a
host already configured for Genesis needs nothing extra.
| Variable | Required | Purpose |
|---|---|---|
KeyVault__KeyVaultUrl |
yes | Vault URL, e.g. https://my-vault.vault.azure.net/ |
KeyVault__ClientId |
no | Service-principal application id |
KeyVault__ClientSecret |
no | Service-principal secret |
KeyVault__TenantId |
no | Directory tenant id |
Authentication tries DefaultAzureCredential first — managed identity on Azure, az login /
Visual Studio / AZURE_* environment variables locally. When that chain has nothing available and
all three of ClientId, ClientSecret, and TenantId are set, it falls back to those. Set them on
hosts with no CLI login and no managed identity. A partially filled set is ignored.
Bad credentials surface on the first vault call, not at startup: resolution happens in a synchronous singleton constructor, so probing the token there would block container build on a network round trip and take the host down on a transient AAD blip.
One vault serves every tenant the host serves, whichever cluster their metadata is on. Vault keys
come from the globally unique secret id, so tenants cannot collide, but the vault rather than the
cluster is the blast radius for values. Separating values per placement means a separate host with
its own KeyVault__KeyVaultUrl, not a code change. The Database store has no such split — its
values sit beside the metadata, so they follow the tenant's placement automatically.
Setup
services.AddBlocksSecrets();
builder.Services.Configure<MvcOptions>(options => options.Filters.Add<SecretExceptionFilter>());
Inject ISecretService wherever you need it.
Types
| Type | Access list | Value readable by | Shown in the Blocks OS UI |
|---|---|---|---|
api |
yes | the access list, the creator, root | reveal, copy, edit, rotate |
service |
no | any authenticated caller in the tenant | metadata only |
both |
no | any authenticated caller in the tenant | reveal, copy, edit, rotate |
both reads identically to service — a valid tenant context and Active status is the
whole check for either. The difference is presentational: the UI offers value and edit actions
for both and hides them for service. That makes an existing capability visible rather than
granting a new one, because the API has always let any authenticated caller in the tenant read
a service value.
The type is fixed at creation. There is no operation that rewrites it, because converting one
in place would silently move an existing credential between access models — a service secret
turned both would become visible to every user in the tenant without anyone re-approving it.
SecretTypes.HasAccessList is the single predicate behind the distinction; four rules depend
on it (whether a create stores an access list, whether UpdateAccessAsync applies, whether a
value read consults the list, and whether a metadata mutation does).
Create
var secretId = await _secrets.SetAsync(new SetSecretRequest
{
Name = "smtp-password",
Value = plaintext,
Type = SecretTypes.Service,
Tags = ["mail", "env:prod"],
});
var ids = await _secrets.SetManyAsync(
[
new SetSecretRequest { Name = "smtp-password", Value = pwd, Type = SecretTypes.Service },
new SetSecretRequest { Name = "smtp-host-key", Value = key, Type = SecretTypes.Service },
]);
// ids["smtp-password"] => "3f2a...c91"
Store the returned id on your own documents. Never the value.
Read
SecretResult? secret = await _secrets.GetAsync(secretId);
SecretListResult page = await _secrets.FindAsync(new SecretFilter
{
Search = "smtp",
Type = SecretTypes.Service,
Tags = ["iam", "os"], // any of them, not all
PageNumber = 1,
PageSize = 20,
});
string value = await _secrets.GetValueAsync(secretId);
IReadOnlyDictionary<string, string> values = await _secrets.GetValuesAsync([id1, id2]);
Tags
Tags are free-form labels for grouping and lookup. They carry no authorization meaning — an access list is still what decides who may read a value.
They are normalized on write: trimmed, lowercased, de-duplicated and ordered. A tag must start
with a letter or digit and may then contain letters, digits, ., _, - and :; the colon
is there so a tag can be namespaced as env:prod. At most 20 tags per secret, 50 characters
each.
UpdateAsync replaces the whole set. Null leaves the existing tags alone; an empty collection
clears them.
await _secrets.UpdateAsync(secretId, new UpdateSecretRequest { Tags = ["mail", "env:staging"] });
Filtering is any-of: a secret matching at least one of the given tags is returned, so selecting a second tag widens the result rather than narrowing it. At most 10 tags per filter.
The catalogue
GetTagsAsync returns the tenant's tag catalogue — key plus display label — for a tag picker
or a tag filter:
IReadOnlyList<SecretTagEntry> tags = await _secrets.GetTagsAsync();
// [{ Key = "iam", Label = "Blocks Iam" }, …]
It lives in the tenant database's keyValueStores collection under my-secret-tags, read and
written through Genesis's IKeyValueStore:
{
"Key": "my-secret-tags",
"Value": [
{ "Key": "iam", "Label": "Blocks Iam" },
{ "Key": "os", "Label": "Blocks Logic" }
]
}
Entries seeded by hand keep the labels they were given. When a create or update carries a tag
the catalogue has not seen, the platform adds it with a label derived from the key
(payments-team becomes "Payments Team"); an existing key is never rewritten. Nothing removes
an entry, so a tag stays on offer after the last secret using it is gone.
The catalogue is a convenience list, not an authority. It never constrains what a secret may be tagged with and is never consulted to validate a tag, so a missing document means "no suggestions" rather than an error, and a failed catalogue write never fails the secret write that triggered it.
Update and rotate
await _secrets.UpdateAsync(secretId, new UpdateSecretRequest
{
Name = "smtp-password-primary",
Description = "Outbound mail",
});
await _secrets.RotateAsync(secretId, new RotateSecretRequest { Value = newPlaintext });
Lifecycle
await _secrets.LockAsync(secretId);
await _secrets.UnlockAsync(secretId);
await _secrets.DeleteAsync(secretId);
await _secrets.RestoreAsync(secretId);
Access
await _secrets.UpdateAccessAsync(secretId, new SecretAccess
{
UserIds = { "user-1", "user-2" },
Roles = { "admin" },
});
Audit
SecretAuditListResult logs = await _secrets.GetAuditLogsAsync(new SecretAuditFilter
{
SecretId = secretId,
FromDate = DateTime.UtcNow.AddDays(-30),
PageNumber = 1,
PageSize = 20,
});
Errors
| Exception | HTTP |
|---|---|
SecretValidationException |
400 |
SecretAccessDeniedException |
403 |
SecretNotFoundException |
404 |
SecretStateException |
409 |
SecretVaultException |
502 |
A tenant that is unknown to the registry or disabled raises SecretAccessDeniedException
(INVALID_CONTEXT), the same as an unauthenticated caller — the store it would have been routed
to is not confirmed either way. A tenant recorded without a connection is a provisioning fault,
not a caller error, and surfaces as an unmapped failure the host reports as 500.
HTTP
| Route | Method | Purpose |
|---|---|---|
/Secrets/gets |
GET | List metadata; ?tags=iam&tags=os filters to secrets carrying any of them |
/Secrets/tags |
GET | The tenant's tag catalogue, for a picker or a filter |
/Secrets/values |
POST | Batch plaintext read by id |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Azure.Identity (>= 1.21.0)
- Azure.Security.KeyVault.Secrets (>= 4.11.0)
- MongoDB.Driver (>= 3.8.0)
- SeliseBlocks.Genesis.OS (>= 4.2.2)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on SeliseBlocks.Secrets.OS:
| Package | Downloads |
|---|---|
|
SeliseBlocks.ReleaseDriver.OS
Blocks Release Driver |
GitHub repositories
This package is not used by any popular GitHub repositories.
The secret store now follows the tenant's database placement: metadata, audit and database-backed values resolve on the connection recorded in the tenant registry instead of always on the main connection. Existing tenants are unaffected, their registry record already names main. An unknown, disabled or unprovisioned tenant now fails instead of reading main. Index creation is ensured once per cluster rather than once per process. SecretStoreContext takes ITenants in place of IBlocksSecret; hosts that resolve it from the container need no change. Key Vault values still live in the one vault the host is configured with, whichever cluster a tenant is on.