Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
119 changes: 58 additions & 61 deletions Axiam.Sdk/Rest/AuthzRestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,21 @@ namespace Axiam.Sdk.Rest;
/// same shared session). Exposed as <c>AxiamClient.Authz</c>.
/// </summary>
/// <remarks>
/// This class holds NO local cache of any authorization decision — every call hits the
/// server fresh. AXIAM's RBAC engine is additive-only (allow-wins, default-deny,
/// SEC-040; project constraint per CLAUDE.md); a client-side cache or short-circuit
/// could silently diverge from the server's live decision, which this SDK must never
/// risk.
/// <para>
/// By default this class holds NO local cache of any authorization decision — every call
/// hits the server fresh, because a client-side cache can silently diverge from the
/// server's live decision. CONTRACT.md &#167;11.2 rule 6 makes that the default and
/// &#167;17 carves out the single opt-in exception: a TTL-bounded
/// <see cref="DecisionMemo"/>, off unless <c>DecisionMemoTtl</c> is set, whose cost
/// (read-your-own-writes is not guaranteed, in both directions) is documented on that
/// option.
/// </para>
/// <para>
/// AXIAM's RBAC engine is default-deny with <b>deny-override</b>: an explicit
/// <c>effect: deny</c> grant refuses regardless of what else allows it, at any depth of
/// the hierarchy. (This remark said "additive-only, allow-wins" until B1 shipped
/// deny-override and closed SEC-040.)
/// </para>
/// </remarks>
public sealed class AuthzRestClient
{
Expand Down Expand Up @@ -99,32 +109,21 @@ private sealed record BatchCheckWireResponse(
public async Task<bool> CheckAccessAsync(
string action, Guid resourceId, string? scope = null, Guid? subjectId = null, CancellationToken cancellationToken = default)
{
var wireRequest = new CheckAccessWireRequest(action, resourceId, scope, subjectId);
HttpResponseMessage response;
try
{
response = await _http.PostAsJsonAsync(CheckPath, wireRequest, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
// Transport-level failure (connection refused, DNS, TLS) — map to the
// documented NetworkError taxonomy (CONTRACT.md §2), matching
// AxiamClient.PostJsonAsync rather than leaking a raw HttpRequestException.
throw NetworkError.FromException(ex, "checkAccess failed");
}

using (response)
{
if (!response.IsSuccessStatusCode)
{
throw ErrorMapper.FromHttpResponse(response, "checkAccess failed");
}

CheckAccessWireResponse? wire = await response.Content
.ReadFromJsonAsync<CheckAccessWireResponse>(cancellationToken: cancellationToken)
.ConfigureAwait(false);
return wire?.Allowed ?? false;
}
// Delegates to CheckAccessDecisionAsync rather than posting directly.
//
// It used to post directly, with no §16 retry budget, no §17 memo and no §19
// request pair — so the most-used method on this class was the one method that
// did none of D5, while the D5 conformance suite (which drives
// CheckAccessDecisionAsync) stayed green. That is precisely the failure §16.7
// was written about: "a tested surface nobody calls is worse than an absent one,
// because the passing tests are what stop anyone from looking." Here the surface
// was called and the tests looked elsewhere — the same hole from the other side.
//
// Delegating, rather than duplicating the instrumentation, is what stops it
// recurring: one instrumented path, and no second one to forget.
AccessDecision decision = await CheckAccessDecisionAsync(
action, resourceId, scope, subjectId, cancellationToken).ConfigureAwait(false);
return decision.Allowed;
}

/// <summary>
Expand Down Expand Up @@ -223,36 +222,11 @@ public Task<bool> CanAsync(string action, Guid resourceId, string? scope = null,
/// </summary>
public async Task<IReadOnlyList<bool>> BatchCheckAsync(IEnumerable<AccessCheck> checks, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(checks);
List<CheckAccessWireRequest> wireChecks = checks
.Select(c => new CheckAccessWireRequest(c.Action, c.ResourceId, c.Scope, c.SubjectId))
.ToList();
var wireRequest = new BatchCheckWireRequest(wireChecks);

HttpResponseMessage response;
try
{
response = await _http.PostAsJsonAsync(BatchCheckPath, wireRequest, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
// Transport-level failure — map to NetworkError (CONTRACT.md §2), matching
// AxiamClient.PostJsonAsync rather than leaking a raw HttpRequestException.
throw NetworkError.FromException(ex, "batchCheck failed");
}

using (response)
{
if (!response.IsSuccessStatusCode)
{
throw ErrorMapper.FromHttpResponse(response, "batchCheck failed");
}

BatchCheckWireResponse? wire = await response.Content
.ReadFromJsonAsync<BatchCheckWireResponse>(cancellationToken: cancellationToken)
.ConfigureAwait(false);
return wire?.Results.Select(r => r.Allowed).ToList() ?? new List<bool>();
}
// Delegates for the same reason CheckAccessAsync does: one instrumented path,
// and no second one to forget.
IReadOnlyList<AccessDecision> decisions =
await BatchCheckDecisionsAsync(checks, cancellationToken).ConfigureAwait(false);
return decisions.Select(d => d.Allowed).ToList();
}

/// <summary>
Expand All @@ -269,7 +243,27 @@ public async Task<IReadOnlyList<AccessDecision>> BatchCheckDecisionsAsync(
List<CheckAccessWireRequest> wireChecks = checks
.Select(c => new CheckAccessWireRequest(c.Action, c.ResourceId, c.Scope, c.SubjectId))
.ToList();

// §16.2 names batch_check as retry-eligible alongside check_access — the same
// side-effect-free POST, just plural. Deliberately NOT memoized: the §17 key is
// per-check, so a batch would split into n entries with n keys, which changes
// what a partial hit means. §17 says nothing about batch, so this takes the
// conservative reading rather than inventing semantics.
return await RetryPolicy.ExecuteAsync(
"BatchCheck",
_options,
_telemetry,
_jitter,
attempt => SendBatchAsync(wireChecks, attempt, cancellationToken),
cancellationToken).ConfigureAwait(false);
}

/// <summary>One §16 attempt at the batch call, with its §19 request pair.</summary>
private async Task<IReadOnlyList<AccessDecision>> SendBatchAsync(
List<CheckAccessWireRequest> wireChecks, int attempt, CancellationToken cancellationToken)
{
var wireRequest = new BatchCheckWireRequest(wireChecks);
TelemetryDispatcher.Span span = _telemetry.StartRequest("BatchCheck", "POST", BatchCheckPath, attempt);

HttpResponseMessage response;
try
Expand All @@ -278,19 +272,22 @@ public async Task<IReadOnlyList<AccessDecision>> BatchCheckDecisionsAsync(
}
catch (HttpRequestException ex)
{
span.End(null, TelemetryOutcome.Failure);
throw NetworkError.FromException(ex, "batchCheck failed");
}

using (response)
{
if (!response.IsSuccessStatusCode)
{
span.End((int)response.StatusCode, TelemetryOutcome.Failure);
throw ErrorMapper.FromHttpResponse(response, "batchCheck failed");
}

BatchCheckWireResponse? wire = await response.Content
.ReadFromJsonAsync<BatchCheckWireResponse>(cancellationToken: cancellationToken)
.ConfigureAwait(false);
span.End((int)response.StatusCode, TelemetryOutcome.Success);
return wire?.Results
.Select(r => new AccessDecision(r.Allowed, r.Reason, r.ReasonCode))
.ToList() ?? new List<AccessDecision>();
Expand Down
48 changes: 47 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Axiam.Sdk (C#) — Examples

Two runnable example projects demonstrating the AXIAM C# SDK's public surface
Three runnable example projects demonstrating the AXIAM C# SDK's public surface
(`Axiam.Sdk` + `Axiam.Sdk.AspNetCore`). Both build under `<Nullable>enable</Nullable>`
and reference the SDK's projects directly (not the published NuGet packages), so
they always exercise the current source tree.
Expand Down Expand Up @@ -82,3 +82,49 @@ pull request, ensuring they stay compilable against the current
SDK source tree. Neither example is executed in CI — running them end-to-end
requires a live AXIAM server (and, for the Quickstart AMQP phase, a live
RabbitMQ broker), which is manual-only per `21-VALIDATION.md`.

## TelemetryHook/

A console app demonstrating the D5 surface — CONTRACT.md §16 (bounded read-only
retry), §17 (decision memo), §18 (`Dispose`) and §19 (telemetry hooks) — with a
sink that aggregates in-process, so it runs with no metrics dependency.

**Build:**

```bash
dotnet build examples/TelemetryHook -c Release
```

**Run** (works without a reachable server — that is the point):

```bash
export AXIAM_BASE_URL=https://your-axiam-instance
export AXIAM_TENANT_ID=your-tenant-slug
dotnet run --project examples/TelemetryHook
```

Pointed at nothing, it prints:

```
WARN: MaxRetryAttempts=25 was clamped to 3 (§16.1)
WARN: DecisionMemoTtl=00:01:00 was clamped to 00:00:05 (§17.1 rule 2)
check failed: checkAccess failed: HttpRequestException — Connection refused
--- telemetry ---
CheckAccess/Failure: count=3 mean=35ms
retries CheckAccess: 2
refreshes: 0
```

Both settings are deliberately configured out of range so the §19.2 rule 6
`ConfigClampedEvent` is something you *see* rather than something the comments
promise. This SDK is where that matters most: `MaxRetryAttempts` was publicly
settable **upward** before D5, which is exactly what §16.1 forbids — a caller
who can raise the cap turns one client into the herd a backoff exists to
prevent. The clamp closed it; the event is what stops the clamp being silent.

The three failed attempts with two retries between them are the §16 budget, and
counting them is only possible because §19.2 rule 5 emits one request pair per
**attempt** rather than per logical call.

The trailing comment in `Program.cs` maps each event onto its
OpenTelemetry / prometheus-net equivalent.
133 changes: 133 additions & 0 deletions examples/TelemetryHook/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System.Collections.Concurrent;
using Axiam.Sdk;
using Axiam.Sdk.Core;
using Axiam.Sdk.Options;

// Telemetry hooks (CONTRACT.md §19): wiring metrics to an AXIAM client WITHOUT
// this package depending on any metrics library.
//
// The sink below aggregates in-process so the example runs with no extra
// dependencies; the comment at the bottom shows the exact mapping onto
// OpenTelemetry / prometheus-net, which is a drop-in replacement for the body.
// Uses ONLY the public Axiam.Sdk surface.
//
// Run: AXIAM_BASE_URL=https://your-axiam AXIAM_TENANT_ID=acme \
// dotnet run --project examples/TelemetryHook

var requests = new ConcurrentDictionary<string, (long Count, long TotalMs)>();
var retries = new ConcurrentDictionary<string, long>();
long refreshes = 0;

void Sink(TelemetryEvent telemetryEvent)
{
switch (telemetryEvent)
{
// One pair per ATTEMPT, not per logical call (§19.2 rule 5), so counting
// these gives the real number of wire calls — including the ones a retry
// made on your behalf.
//
// RequestStartEvent is deliberately not handled: RequestEndEvent carries
// the same identity plus the outcome, so counting both double-counts.
case RequestEndEvent e:
requests.AddOrUpdate(
$"{e.Operation}/{e.Outcome}",
_ => (1, (long)e.Duration.TotalMilliseconds),
(_, prev) => (prev.Count + 1, prev.TotalMs + (long)e.Duration.TotalMilliseconds));
break;

// §16.5 — the reason this event exists. A retried-then-succeeded
// operation is otherwise invisible: the caller sees a slow success and no
// signal that the server is failing. Alert on THIS rate, not on the error
// rate, or a degrading server looks healthy right up until the retries
// stop being enough.
case RetryEvent e:
retries.AddOrUpdate(e.Operation, _ => 1, (_, prev) => prev + 1);
break;

case RefreshEvent:
Interlocked.Increment(ref refreshes);
break;

// §19.2 rule 6 — fired at most once per clamped setting, at construction.
// Worth logging loudly rather than counting: it means a value in your
// configuration is not the value in force, and the gap is silent
// everywhere else.
case ConfigClampedEvent e:
Console.Error.WriteLine(
$"WARN: {e.Setting}={e.Requested} was clamped to {e.Effective} ({e.ContractReference})");
break;
}
}

Uri baseUrl = new(Environment.GetEnvironmentVariable("AXIAM_BASE_URL") ?? "https://localhost:8443");
string tenantId = Environment.GetEnvironmentVariable("AXIAM_TENANT_ID") ?? "acme";

using AxiamClient client = new(baseUrl, tenantId, new AxiamClientOptions
{
BaseUrl = baseUrl,
TenantId = tenantId,
TelemetryHook = Sink,

// Deliberately above the §17.1 rule 2 ceiling, so the run demonstrates the
// ConfigClampedEvent warning above rather than leaving it theoretical.
DecisionMemoTtl = TimeSpan.FromSeconds(60),

// Deliberately above the §16.1 cap, for the same reason. This SDK is the one
// where the clamp matters most: MaxRetryAttempts was publicly settable
// *upward* before D5, which is what §16.1 forbids — a caller who can raise
// the cap turns one client into the herd a backoff exists to prevent.
MaxRetryAttempts = 25,
});

try
{
Guid documentId = Guid.Parse(
Environment.GetEnvironmentVariable("AXIAM_RESOURCE_ID") ?? Guid.Empty.ToString());
AccessDecision decision = await client.Authz.CheckAccessDecisionAsync("documents:read", documentId);
Console.WriteLine($"allowed={decision.Allowed} reasonCode={decision.ReasonCode ?? "(absent)"}");
}
// The §2 taxonomy is three sealed exception types with no shared base in this
// SDK, so "any AXIAM error" is spelled as the three of them rather than as one
// catch. Worth knowing before you write the same handler in your own code.
catch (Exception ex) when (ex is NetworkError or AuthError or AuthzError)
{
// Expected when no server is reachable — the point of the example is the
// telemetry below, which is emitted either way.
Console.WriteLine($"check failed: {ex.Message}");
}

Console.WriteLine("--- telemetry ---");
foreach ((string key, (long count, long totalMs)) in requests.OrderBy(kv => kv.Key))
{
Console.WriteLine($" {key}: count={count} mean={(count == 0 ? 0 : totalMs / count)}ms");
}
if (retries.IsEmpty)
{
Console.WriteLine(" retries: (none)");
}
foreach ((string op, long n) in retries.OrderBy(kv => kv.Key))
{
Console.WriteLine($" retries {op}: {n}");
}
Console.WriteLine($" refreshes: {Interlocked.Read(ref refreshes)}");

// §18: `using` disposes the client, releasing the HttpClient and its handler
// chain. Dispose issues NO request — it does not log out, because the
// server-side session deliberately outlives the client object. Any call after
// disposal throws rather than silently rebuilding the transport.

/*
* Mapping onto a real backend — replace Sink's body, nothing else:
*
* RequestEndEvent → histogram "axiam.request.duration"
* tags: operation, path_template, status_code, outcome, attempt
* RetryEvent → counter "axiam.request.retries" tags: operation
* RefreshEvent → counter "axiam.token.refresh" tags: role
* ConfigClampedEvent → a log line at Warning, not a metric: it fires once at
* construction and its whole value is being READ.
*
* Tag with PathTemplate, never with the request URL: a metric tag carrying a
* UUID is a cardinality bomb. The hook runs on the calling thread, so it must
* not block — every mature metrics library already buffers, which is why §19.2
* rule 4 leaves that choice to you rather than making it here.
*/
21 changes: 21 additions & 0 deletions examples/TelemetryHook/TelemetryHook.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>TelemetryHook</RootNamespace>
<!-- Example only — demonstrates the SDK's public surface; never packed or
published as a NuGet package. -->
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<!-- Core package only (Axiam.Sdk) — REST + gRPC + AMQP are all part of the
same package (D-03); no ASP.NET Core dependency needed for a console
quickstart. -->
<ProjectReference Include="../../Axiam.Sdk/Axiam.Sdk.csproj" />
</ItemGroup>

</Project>
Loading
Loading