Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
20006b0
feat(tower): refine button shape language and Projects page actions
mrtaikandi May 9, 2026
01fd874
chore: normalize aspire.config.json formatting
mrtaikandi May 9, 2026
9ce1675
feat(tower): show full template description on hover in attach list
mrtaikandi May 9, 2026
b6f1bbe
feat(tower): align split diff columns and unify their visual surface
mrtaikandi May 9, 2026
3509be1
feat(tower): remove InlineCode component for client name display
mrtaikandi May 9, 2026
138a49f
feat(tower): extract reusable filter button and search popover
mrtaikandi May 9, 2026
e847fcc
feat(api): allow updating client scope context
mrtaikandi May 9, 2026
4f865bd
feat(tower): redesign Clients page with global new/edit dialogs
mrtaikandi May 9, 2026
744e934
feat(tower): redesign Scopes page with filter, stacked cards, and inl…
mrtaikandi May 9, 2026
d9946b8
feat(tower): adopt projects-style divided list on Clients and Scopes …
mrtaikandi May 9, 2026
1dbbafa
feat(tower): redesign Variables page with reveal/copy value cell
mrtaikandi May 9, 2026
f6e1be3
docs(guide): add variables reference page
mrtaikandi May 9, 2026
694140a
feat(tower): redesign Templates page and reuse config views
mrtaikandi May 9, 2026
72ecb65
feat(tower): split Variables UX into global page and per-project tab
mrtaikandi May 9, 2026
484196a
style(tower): apply Title Case to titles, headings, and Variable buttons
mrtaikandi May 9, 2026
eae8577
style(tower): use Variable icon for project Variables tab
mrtaikandi May 9, 2026
78070c0
fix(tower): scoped value dropdown first-pick and drop Tier readout
mrtaikandi May 9, 2026
c9a0cdf
refactor(tower): replace ScopedEntryValue with inline EntryValue and …
mrtaikandi May 9, 2026
b0296c6
fix(tower): fix tier filtering logic and navigation in VariablesRoute
mrtaikandi May 9, 2026
6050a96
fix(tower): fix layout and spacing in ActivityRow component
mrtaikandi May 9, 2026
0bcc5ac
feat(tower): enhance EntryModal with sensitive value handling and Sco…
mrtaikandi May 9, 2026
7ea539d
refactor(tower): simplify ScopedValuesField by removing unnecessary t…
mrtaikandi May 9, 2026
2ae70d4
feat(tower): add background and foreground colors for destructive but…
mrtaikandi May 9, 2026
2572e8f
fix(api): preserve client scopes when UpdateClient request omits them
mrtaikandi May 9, 2026
dd98268
fix(tower): stop auto-binding clients to unrelated scope dimensions
mrtaikandi May 9, 2026
6ed7319
refactor(tower): drop no-op valuesAreMasked memo and tighten ScopedVa…
mrtaikandi May 9, 2026
db0d013
test(tower): cover EntryModal sensitive reveal flow
mrtaikandi May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions aspire.config.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"appHost" : {
"language" : "csharp",
"path" : "./src/GroundControl.AppHost/GroundControl.AppHost.csproj"
"appHost": {
"language": "csharp",
"path": "./src/GroundControl.AppHost/GroundControl.AppHost.csproj"
},
"features" : {
"defaultWatchEnabled" : true,
"showAllTemplates" : true,
"updateNotificationsEnabled" : true
"features": {
"defaultWatchEnabled": "true",
"showAllTemplates": "true",
"updateNotificationsEnabled": "true"
}
}
2 changes: 2 additions & 0 deletions docs/guide/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Use variables for values that appear in many entries, such as a connection strin

Variables are resolved at publish time. If a configuration value references a variable that is undefined or cannot be resolved for the target scope, the publish fails with an error telling you exactly which variable is missing.

For a full reference of variable structure, ownership tiers, two-tier resolution, sensitivity, and group/system-wide visibility rules, see [Variables](variables.md).

## Configuration Entries

Configuration entries are the individual key-value pairs that make up your configuration. Each entry has:
Expand Down
213 changes: 213 additions & 0 deletions docs/guide/variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Variables

Variables are named placeholders that get interpolated into configuration entry values at snapshot publish time. They let you keep one source of truth for any value that appears in many entries — connection-string prefixes, API endpoints, shared secrets — and change it in one place instead of editing every entry that uses it.

This page covers what a variable looks like, how its visibility is determined, how a placeholder gets resolved, and the edge cases you need to know about.

## Anatomy of a variable

A variable has a name, an ownership tier, and a list of values. Each value is qualified by zero or more scope dimensions.

| Field | Type | Purpose |
|---|---|---|
| `name` | string | The key used in `{{name}}` placeholders. Case-insensitive within its uniqueness key. |
| `description` | string? | Optional human-readable note. |
| `scope` | `Global` \| `Project` | Ownership tier. See [Ownership tiers](#ownership-tiers). |
| `groupId` | Guid? | For `Global` variables only. `null` means system-wide; otherwise the variable belongs to that group. Forbidden on `Project` variables. |
| `projectId` | Guid? | Required on `Project` variables; forbidden on `Global` variables. |
| `values` | `ScopedValue[]` | One or more scoped value variants. See [Scoped values](#scoped-values). |
| `isSensitive` | bool | Encrypts at rest, masks as `***` in API responses, and propagates sensitivity to any snapshot entry that interpolates the variable. |
| `version` | long | Optimistic-concurrency token. Required on update/delete via `If-Match`. |

The full field list including audit timestamps is in [Domain Model — Variable](../design-docs/Domain-Model.md#variable).

## Ownership tiers

The `scope` field puts a variable in one of two tiers:

### Global

`scope = Global`. Used to define values shared across many projects.

`groupId` controls visibility:

- **`groupId = null`** — system-wide global. Every project, in every group (and ungrouped projects), can resolve this variable.
- **`groupId = X`** — group-owned global. Only projects whose `Project.GroupId` equals `X` can resolve it.

`projectId` must be `null` on global variables.

### Project

`scope = Project`. Used to override a global variable's value for one specific project, or to define a value that only that project needs.

`projectId` is required and must reference an existing project. `groupId` must be `null` — a project variable inherits its group through the project.

A project variable with the same `name` as a global variable shadows the global for that project (see [Two-tier resolution](#two-tier-resolution)).

## Scoped values

Each entry in `values` represents the variable's value for a specific scope combination:

```json
{
"scopes": { "Environment": "prod", "Region": "eu" },
"value": "https://api.eu.example.com"
}
```

- `scopes` is a dimension → value map. Dimensions must already exist in the Scopes registry; values must be in the dimension's allowed-values set. Validated on write by [`CreateVariableValidator`](../../src/GroundControl.Api/Features/Variables/CreateVariableValidator.cs).
- An empty `scopes` map (`{}`) marks the **unscoped default** — used when no scoped variant matches the requesting client.
- `value` is always a string. The interpolation rule below treats it as a literal: variable values **cannot themselves contain `{{...}}`** placeholders. Nested interpolation is rejected on write.

A single variable typically holds one unscoped default plus one variant per environment/region/tier combination it needs to differ on.

## How a placeholder resolves

When a snapshot is published for a project, every config entry value is scanned for `{{name}}` placeholders. Each placeholder is resolved using a **two-tier**, **most-specific-scope-wins** algorithm.

### Two-tier resolution

For each placeholder `{{name}}`:

1. Look up `name` in the project's project-scope variables.
2. If found, attempt scope resolution against the client's scopes (see below). If a value resolves, use it.
3. Otherwise, look up `name` in the project's visible globals.
4. If a global match resolves, use it.
5. If neither tier yields a value, the placeholder is **unresolved** and the publish fails with the offending name reported back.

The implementation lives in [`VariableInterpolator`](../../src/GroundControl.Api/Features/Snapshots/VariableInterpolator.cs).

### Scope resolution within a tier

Within a single variable's `values` list, [`ScopeResolver`](../../src/GroundControl.Api/Shared/Resolvers/ScopeResolver.cs) picks one variant:

1. Filter to candidates whose `scopes` map is a **full match** of the client's scopes — every dimension in the candidate must equal the client's value (case-insensitive on the dimension name, exact on the value).
2. Of the matches, the candidate with the **most dimensions** wins.
3. If no scoped candidate matches, fall back to the unscoped default (`scopes = {}`).
4. If there isn't even an unscoped default, the variable contributes no value and resolution falls through to the next tier (or fails).

A tie at the same specificity logs a warning and returns the first match — design your scoped values so combinations don't collide.

### Visibility from a project's perspective

For a project `P` in group `G`, the variables visible at publish time are:

| Source | Visible? |
|---|---|
| Project variables where `projectId = P.id` | Always |
| Global variables where `groupId = G` | Yes |
| Global variables where `groupId = null` (system-wide) | Yes |
| Global variables where `groupId = some other group` | **No** |
| Project variables on a different project | **No** |

Implemented by [`VariableStore.GetGlobalVariablesForGroupAsync`](../../src/GroundControl.Persistence.MongoDb/Stores/VariableStore.cs) and [`SnapshotResolver.ResolveAndInterpolateAsync`](../../src/GroundControl.Api/Features/Snapshots/SnapshotResolver.cs).

## Sensitivity

Setting `isSensitive = true` does three things:

1. **Encryption at rest** — values are encrypted by `SensitiveSourceValueProtector` before being written to MongoDB.
2. **Masking on read** — API responses replace each value with `***` unless the caller has the `sensitive_values:decrypt` permission and adds `?decrypt=true`.
3. **Sensitivity propagation** — any snapshot config entry that interpolates a sensitive variable is itself treated as sensitive. The flag flips on the resolved entry even if the entry was authored as non-sensitive.

The mask sentinel `***` is reserved: you cannot save a sensitive variable whose plaintext value is literally `***` (the validator rejects it) — it would otherwise be indistinguishable from a masked read.

## Choosing the right tier

| You want… | Use |
|---|---|
| One value usable by every project in the system | `Global`, `groupId = null` |
| One value shared across every project in a single group | `Global`, `groupId = <group>` |
| A per-project tweak of a shared value (same name) | `Project` variable with the same `name` as the global |
| A value only one project ever uses | `Project` variable, no global counterpart |
| Different values per environment but the same name everywhere | One variable with multiple `ScopedValue` entries (`{Environment: prod}`, `{Environment: staging}`, plus an unscoped default) |
| Sharing a single value across **two specific groups** but not others | Not directly supported — either make it system-wide and accept the broader visibility, or duplicate it as a group-owned global in each group |

## Uniqueness rules

Enforced by partial unique indexes (case-insensitive) in [`VariableConfiguration`](../../src/GroundControl.Persistence.MongoDb/Conventions/VariableConfiguration.cs):

- `(scope=Global, groupId, name)` is unique. Two globals can share a name only if they have different `groupId`s (including `null`).
- `(scope=Project, projectId, name)` is unique.

`name` is treated case-insensitively for both uniqueness and placeholder lookup.

## Sharp edges

- **Same name at system-wide and group tier.** A `Global` variable with `groupId = null` and another `Global` variable with `groupId = X` are both stored — the unique index allows it because `groupId` differs. From a project in group `X`, both end up in the same lookup dictionary keyed by name, so whichever the dictionary build encounters last wins. The result is **order-dependent**. Don't rely on this for project-specific overrides — use a `Project`-scope variable instead.
- **No multi-group sharing.** There is no link table, no `groupId[]`, and no template-style attachment. A variable belongs to exactly one tier (system-wide or one group, or one project).
- **Variables can't reference variables.** `{{...}}` is rejected on write inside variable values; only config-entry values may contain placeholders.
- **Resolution is publish-time, not write-time.** A config entry can be saved with `{{Foo}}` even if `Foo` doesn't exist yet. The publish call is what fails when the placeholder can't be resolved.
- **Tied scope specificity.** If two scoped values in the same variable match a client with the same dimension count, you get a warning log and a non-deterministic pick. Make scope combinations unambiguous.

## Worked examples

### Shared API endpoint with environment overrides

One system-wide variable, used by every project, varying by environment:

```bash
curl -X POST http://localhost:8080/api/variables \
-H "Content-Type: application/json" \
-H "api-version: 1.0" \
-d '{
"name": "ApiBase",
"scope": "Global",
"isSensitive": false,
"values": [
{ "scopes": {}, "value": "https://api.example.com" },
{ "scopes": { "Environment": "staging" }, "value": "https://api.staging.example.com" },
{ "scopes": { "Environment": "prod" }, "value": "https://api.example.com" }
]
}'
```

In a config entry:

```json
{ "key": "Endpoints:Api", "valueType": "String",
"values": [{ "value": "{{ApiBase}}/v1" }] }
```

A client bound to `{Environment: staging}` resolves to `https://api.staging.example.com/v1`.

### Group-owned secret with a per-project override

A group-owned global database connection string:

```json
{
"name": "PrimaryDb",
"scope": "Global",
"groupId": "<billing-group-id>",
"isSensitive": true,
"values": [
{ "scopes": {}, "value": "Server=db.billing.internal;Database=core;" },
{ "scopes": { "Environment": "prod" }, "value": "Server=prod-db.billing.internal;Database=core;Encrypt=True;" }
]
}
```

One project in that group needs to point at a dedicated read-replica. Define a project variable with the same name:

```json
{
"name": "PrimaryDb",
"scope": "Project",
"projectId": "<reports-project-id>",
"isSensitive": true,
"values": [
{ "scopes": { "Environment": "prod" }, "value": "Server=prod-db-reports.billing.internal;Database=core;Encrypt=True;ApplicationIntent=ReadOnly;" }
]
}
```

The reports project in `prod` resolves `{{PrimaryDb}}` to the read-replica string. In any other environment the project variable has no matching scope, so resolution falls back to the global's unscoped default. Other projects in the same group are unaffected — they keep using the global value.

## Related

- [Core Concepts — Variables](concepts.md#variables) — short conceptual overview
- [API Reference — Variables](api/endpoints.md#variables) — endpoint shapes
- [CLI — `variable` commands](../cli/configuration.md#variable-----manage-variables)
- [Domain Model — Variable](../design-docs/Domain-Model.md#variable) — full field reference
- [Data Model — `variables`](../design-docs/Data-Model.md#variables) — persistence layout and indexes
1 change: 1 addition & 0 deletions src/GroundControl.Api/Features/Clients/ClientsModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public void OnServiceConfiguration(WebApplicationBuilder builder)
builder.Services.AddTransient<DeleteClientHandler>();

builder.Services.AddTransient<IAsyncValidator<CreateClientRequest>, CreateClientValidator>();
builder.Services.AddTransient<IAsyncValidator<UpdateClientRequest>, UpdateClientValidator>();
builder.Services.AddTransient<DeleteClientValidator>();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ internal sealed record UpdateClientRequest
/// </summary>
public required bool IsActive { get; init; }

/// <summary>
/// Gets the fixed scope assignments for the client. When provided, replaces the existing scope context.
/// </summary>
public Dictionary<string, string>? Scopes { get; init; }

/// <summary>
/// Gets the optional expiration timestamp.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
}
}
31 changes: 31 additions & 0 deletions src/GroundControl.Api/Features/Clients/UpdateClientHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ public static void Endpoint(IEndpointRouteBuilder endpoints)
HttpContext httpContext,
[FromServices] UpdateClientHandler handler,
CancellationToken cancellationToken = default) => await handler.HandleAsync(projectId, id, request, httpContext, cancellationToken))
.WithContractValidation<UpdateClientRequest>()
.RequireAuthorization(Permissions.ClientsWrite)
.WithSummary("Update a client")
.WithDescription("Updates an existing client. Requires an If-Match header with the current ETag value.")
.Produces<ClientResponse>()
.ProducesValidationProblem()
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict)
.ProducesProblem(StatusCodes.Status428PreconditionRequired)
Expand All @@ -56,10 +58,21 @@ private async Task<IResult> HandleAsync(Guid projectId, Guid id, UpdateClientReq
var oldName = client.Name;
var oldIsActive = client.IsActive;
var oldExpiresAt = client.ExpiresAt;
var oldScopes = new Dictionary<string, string>(client.Scopes);

client.Name = request.Name;
client.IsActive = request.IsActive;
client.ExpiresAt = request.ExpiresAt;

if (request.Scopes is not null)
{
client.Scopes.Clear();
foreach (var (dimension, value) in request.Scopes)
{
client.Scopes[dimension] = value;
}
}

client.UpdatedAt = DateTimeOffset.UtcNow;
client.UpdatedBy = Guid.Empty;

Expand All @@ -73,11 +86,29 @@ private async Task<IResult> HandleAsync(Guid projectId, Guid id, UpdateClientReq
.. AuditRecorder.CompareFields("Name", oldName, client.Name),
.. AuditRecorder.CompareFields("IsActive", oldIsActive.ToString(), client.IsActive.ToString()),
.. AuditRecorder.CompareFields("ExpiresAt", oldExpiresAt?.ToString("O"), client.ExpiresAt?.ToString("O")),
.. CompareScopes(oldScopes, client.Scopes),
];

await _audit.RecordAsync("Client", client.Id, null, "Updated", changes, cancellationToken: cancellationToken).ConfigureAwait(false);

httpContext.Response.Headers.ETag = EntityTagHeaders.Format(client.Version);
return TypedResults.Ok(ClientResponse.From(client));
}

private static IEnumerable<FieldChange> CompareScopes(IReadOnlyDictionary<string, string> oldScopes, IReadOnlyDictionary<string, string> newScopes)
{
var dimensions = new HashSet<string>(oldScopes.Keys, StringComparer.Ordinal);
dimensions.UnionWith(newScopes.Keys);

foreach (var dimension in dimensions)
{
oldScopes.TryGetValue(dimension, out var oldValue);
newScopes.TryGetValue(dimension, out var newValue);

foreach (var change in AuditRecorder.CompareFields($"Scopes.{dimension}", oldValue, newValue))
{
yield return change;
}
}
}
}
39 changes: 39 additions & 0 deletions src/GroundControl.Api/Features/Clients/UpdateClientValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using GroundControl.Api.Features.Clients.Contracts;
using GroundControl.Persistence.Stores;

namespace GroundControl.Api.Features.Clients;

internal sealed class UpdateClientValidator : IAsyncValidator<UpdateClientRequest>
{
private readonly IScopeStore _scopeStore;

public UpdateClientValidator(IScopeStore scopeStore)
{
_scopeStore = scopeStore ?? throw new ArgumentNullException(nameof(scopeStore));
}

public async Task<ValidatorResult> ValidateAsync(UpdateClientRequest instance, ValidationContext context, CancellationToken cancellationToken = default)
{
var result = new ValidatorResult();

if (instance.Scopes is { Count: > 0 })
{
foreach (var (dimension, value) in instance.Scopes)
{
var scope = await _scopeStore.GetByDimensionAsync(dimension, cancellationToken).ConfigureAwait(false);
if (scope is null)
{
result.AddError($"Scope dimension '{dimension}' was not found.", nameof(instance.Scopes));
continue;
}

if (!scope.AllowedValues.Contains(value))
{
result.AddError($"Value '{value}' is not allowed for scope dimension '{dimension}'.", nameof(instance.Scopes));
}
}
}

return result.IsFailed ? result : ValidatorResult.Success;
}
}
Loading
Loading