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
22 changes: 14 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,22 @@ jobs:
- name: Log version
run: echo "Building version ${{ steps.dotnet.outputs.version }}"

- name: Restore backend dependencies
run: dotnet restore Modgud.slnx
working-directory: ./src/dotnet

- name: Audit NuGet dependencies for known CVEs
# Restore IS the CVE audit. NuGet's restore-time audit (NU1901-NU1904, configured
# in src/dotnet/Directory.Build.props) is used rather than
# `dotnet list package --vulnerable`, because that command queries the advisory
# database directly and IGNORES <NuGetAuditSuppress> — leaving no supported way to
# record a reviewed, justified exception. Restore honours it.
#
# The two steps are deliberately one: a second `dotnet restore` is incremental and
# emits no warnings at all, so a separate audit step would pass by doing nothing.
- name: Restore backend dependencies (audits for known CVEs)
run: |
set -o pipefail
out=$(dotnet list package --vulnerable --include-transitive 2>&1)
echo "$out"
if echo "$out" | grep -qE "has the following vulnerable packages"; then
# tee, not command substitution: a captured $(...) under `bash -e` aborts the
# step BEFORE anything is echoed, so a restore that fails outright prints
# nothing at all and the log shows only an exit code.
dotnet restore Modgud.slnx 2>&1 | tee restore.log
if grep -qE "warning NU190[1-4]" restore.log; then
echo "::error ::Vulnerable NuGet packages detected. See log above."
exit 1
fi
Expand Down
33 changes: 33 additions & 0 deletions src/dotnet/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,37 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<!-- CVE auditing. Pinned explicitly rather than left to SDK defaults, because CI's
gate IS this audit (see .github/workflows/ci.yml): restore emits NU1901-NU1904
and the job fails on them. The `dotnet list package` vulnerability report is
deliberately not used: it queries the advisory database directly and ignores the
suppression below, leaving no supported way to record a reviewed exception.
Mode 'all' covers transitive packages; level 'low' means nothing is filtered out
by severity — an exception has to be argued, not silently outranked. -->
<PropertyGroup>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>low</NuGetAuditLevel>
</PropertyGroup>

<!-- GHSA-23fw-v26w-5fgq — Microsoft.Build.Tasks.Git 10.0.301, pulled in
transitively by Microsoft.SourceLink.GitHub in the two packable
projects (Modgud.Provisioning.TestKit, Modgud.AspNetCore.ResourceServer).

Suppressed deliberately, and narrowly — this entry names ONE advisory,
it does not soften the audit:

* SourceLink is referenced with PrivateAssets=all. It is an MSBuild
task that runs at PACK time to embed git metadata; it ships in
neither the produced package nor the running product, and never
reaches a consumer's dependency graph.
* There is no fix to take. Every version above 10.0.301 is an
11.0.100 preview or rc; nothing stable carries the patch.

Remove this the moment a stable Microsoft.SourceLink.GitHub 11.x is
released — the suppression is a wait, not a verdict. -->
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-23fw-v26w-5fgq" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -264,18 +264,34 @@ await CreateClientCredentialsClientAsync(
Assert.Contains(appSlug, body);
}

using var foreignApply = await bearerClient.PostAsJsonAsync(
"/api/admin/realm-config/apply",
new
{
Realm = new { Slug = $"foreign-{Guid.NewGuid():N}" },
Apps = Array.Empty<object>(),
},
JsonOptions,
ct);
var foreignBody = await foreignApply.Content.ReadAsStringAsync(ct);
Assert.Equal(HttpStatusCode.BadRequest, foreignApply.StatusCode);
Assert.Contains("Manifest.SlugMismatch", foreignBody);
// A delegated bearer cannot reach another realm — and since a manifest carries no
// realm identity, that is now structural rather than guarded: there is no field to
// aim elsewhere with. A body still carrying the retired "Realm" object (an older
// export, a hand-written file) is accepted and IGNORED, and the write lands in the
// caller's own realm, which the export below proves.
var strayApp = $"realm-config-stray-{Guid.NewGuid():N}";
using (var stray = await bearerClient.PostAsJsonAsync(
"/api/admin/realm-config/apply",
new
{
Realm = new { Slug = $"foreign-{Guid.NewGuid():N}" },
Apps = new[]
{
new { Slug = strayApp, DisplayName = "Stray", Permissions = Array.Empty<object>() },
},
},
JsonOptions,
ct))
{
var strayBody = await stray.Content.ReadAsStringAsync(ct);
Assert.True(stray.IsSuccessStatusCode,
$"stray-realm apply should be accepted and the Realm object ignored ({(int)stray.StatusCode}): {strayBody}");
}

using (var reexport = await bearerClient.GetAsync("/api/admin/realm-config/export", ct))
{
Assert.Contains(strayApp, await reexport.Content.ReadAsStringAsync(ct));
}
}

[Fact]
Expand Down
31 changes: 20 additions & 11 deletions src/dotnet/Modgud.Api.Tests/ColdStart/ProvisioningTestKitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Modgud.Authorization.Apps;
using Modgud.Infrastructure.Persistence.Tenancy;
using Modgud.Infrastructure.Realms;
using System.Net;
using Modgud.Provisioning.TestKit;

namespace Modgud.Api.Tests.ColdStart;
Expand All @@ -28,7 +29,7 @@ public async Task TestKit_imports_applies_and_hard_deletes_a_realm()
var kit = new ModgudProvisioningClient(httpClient);
const string slug = "kittest";

var realm = await kit.ImportRealmAsync(BuildManifest(slug, "Kit App"), ct);
var realm = await kit.ImportRealmAsync(BuildSpec(slug), BuildManifest(slug, "Kit App"), ct);

// The handle surfaces everything an app-under-test needs.
Assert.Equal(slug, realm.Slug);
Expand Down Expand Up @@ -60,22 +61,30 @@ public async Task TestKit_surfaces_the_server_error_code_on_duplicate_import()
var kit = new ModgudProvisioningClient(await factory.CreateRealmAdminAndLoginAsync());

const string slug = "kitdup";
await using var first = await kit.ImportRealmAsync(BuildManifest(slug, "Dup"), ct);
await using var first = await kit.ImportRealmAsync(BuildSpec(slug), BuildManifest(slug, "Dup"), ct);

var ex = await Assert.ThrowsAsync<ModgudProvisioningException>(
() => kit.ImportRealmAsync(BuildManifest(slug, "Dup"), ct));
Assert.Equal("Realm.AlreadyExists", ex.Code);
() => kit.ImportRealmAsync(BuildSpec(slug), BuildManifest(slug, "Dup"), ct));

// The duplicate is caught by the create step. Every endpoint now answers in one
// shape, so the machine-readable code survives the whole way to the caller —
// before the shapes were unified this step could only hand back prose.
Assert.Equal("create-realm", ex.Operation);
Assert.Equal(HttpStatusCode.Conflict, ex.StatusCode);
Assert.Equal("Realm.DuplicateSlug", ex.Code);
Assert.Contains(slug, ex.Message);
}

private static RealmSpec BuildSpec(string slug) => new()
{
Slug = slug,
DisplayName = slug,
Domains = [$"{slug}.localhost"],
InitialAdmin = new InitialAdmin { UserName = "admin", Email = $"admin@{slug}.test" },
};

private static RealmManifest BuildManifest(string slug, string appDisplayName) => new()
{
Realm = new RealmSpec
{
Slug = slug,
DisplayName = slug,
Domains = [$"{slug}.localhost"],
InitialAdmin = new InitialAdmin { UserName = "admin", Email = $"admin@{slug}.test" },
},
Apps =
[
new RealmManifestApp
Expand Down
45 changes: 31 additions & 14 deletions src/dotnet/Modgud.Api.Tests/ColdStart/RealmConfigEndpointsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,25 +56,33 @@ await InTenantAsync(factory, TenantConstants.SystemTenantId, async sp =>
}

[Fact]
public async Task Apply_refuses_a_manifest_targeting_a_different_realm()
public async Task Apply_ignores_a_stray_realm_object_and_lands_in_the_callers_own_realm()
{
await using var host = await Fixture.CreateIsolatedHostAsync();
var factory = host.Factory;
var ct = TestContext.Current.CancellationToken;
var client = await factory.CreateRealmAdminAndLoginAsync();

// A realm admin may only manage their own realm — a foreign slug is the data-plane boundary.
var foreign = new
// A manifest names no realm any more, so the data-plane boundary is structural rather
// than a guard: there is no field a caller could aim elsewhere. A body that still
// carries the retired "Realm" object (an older export, a hand-written file) stays
// loadable — the property is simply ignored, NOT honoured.
var stray = new
{
Realm = new { Slug = "some-other-realm" },
Apps = new[] { new { Slug = "x", DisplayName = "X", Permissions = new object[0] } },
Apps = new[] { new { Slug = "stray-app", DisplayName = "Stray", Permissions = new object[0] } },
};

var resp = await client.PostAsJsonAsync(
"/api/admin/realm-config/apply", foreign, factory.JsonOptions, ct);
"/api/admin/realm-config/apply", stray, factory.JsonOptions, ct);
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);

Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
Assert.Contains("Manifest.SlugMismatch", await resp.Content.ReadAsStringAsync(ct));
// It landed in the caller's realm, not the one the stray object named.
await InTenantAsync(factory, TenantConstants.SystemTenantId, async sp =>
{
var session = sp.GetRequiredService<IDocumentSession>();
Assert.True(await session.Query<App>().AnyAsync(a => !a.IsDeleted && a.Slug == "stray-app", ct));
});
}

[Fact]
Expand Down Expand Up @@ -206,27 +214,37 @@ public async Task Apply_is_atomic_a_failing_section_rolls_back_earlier_writes()
var ct = TestContext.Current.CancellationToken;
var client = await factory.CreateRealmAdminAndLoginAsync();

// Apps apply before roles. The role references an unknown app, so the apply
// fails AFTER the app section already ran — ADR-0017 Phase 0 demands the
// Apps apply before clients. The client carries an unparseable AccessTokenType, so
// the apply fails AFTER the app section already ran — ADR-0017 Phase 0 demands the
// whole transaction rolls back and the app never materializes.
//
// NOTE: an unresolvable REFERENCE is deliberately not usable as the trigger here —
// those are skipped rather than fatal. A malformed value still is: the manifest says
// something the domain cannot mean, as opposed to naming something absent.
var manifest = new
{
Realm = new { },
Apps = new[]
{
new { Slug = "atomic-app", DisplayName = "Atomic App",
Permissions = new[] { new { Resource = "atomic", Action = "read" } } },
},
Roles = new[]
Clients = new[]
{
new { Name = "Broken Role", App = "no-such-app", Permissions = new object[0] },
new
{
ClientId = "atomic-web", DisplayName = "Atomic Web", ClientType = "public",
RedirectUris = new[] { "https://atomic.test/cb" },
Scopes = new[] { "openid" },
AllowedGrantTypes = new[] { "authorization_code" },
AccessTokenType = "Bogus",
},
},
};

var resp = await client.PostAsJsonAsync(
"/api/admin/realm-config/apply", manifest, factory.JsonOptions, ct);
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
Assert.Contains("Manifest.UnknownApp", await resp.Content.ReadAsStringAsync(ct));
Assert.Contains("Manifest.InvalidEnum", await resp.Content.ReadAsStringAsync(ct));

await InTenantAsync(factory, TenantConstants.SystemTenantId, async sp =>
{
Expand All @@ -238,7 +256,6 @@ await InTenantAsync(factory, TenantConstants.SystemTenantId, async sp =>
// The realm is untouched, so the SAME manifest minus the broken role applies cleanly.
var fixedManifest = new
{
Realm = new { },
Apps = new[]
{
new { Slug = "atomic-app", DisplayName = "Atomic App",
Expand Down
Loading
Loading