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
234 changes: 227 additions & 7 deletions src/SelfService.Tests/Application/TestRbacApplicationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,34 @@ public static async Task<RbacInMemoryTestFixture> NewInMemoryFixture(
var fixture = new RbacInMemoryTestFixture(databaseFactory, dbContext, application);
return fixture;
}

public static async Task<RbacRoleId> SeedGuestRole(
SelfServiceDbContext dbContext,
params (RbacNamespace Namespace, string Name, RbacAccessType Type)[] permissions
)
{
var guest = RbacRole.New("system", "Guest", "Role: Guest", RbacAccessType.Global);
dbContext.RbacRoles.Add(guest);

foreach (var p in permissions)
{
dbContext.RbacPermissionGrants.Add(
new RbacPermissionGrant(
id: RbacPermissionGrantId.New(),
createdAt: DateTime.Now,
assignedEntityType: AssignedEntityType.Role,
assignedEntityId: guest.Id.ToString(),
@namespace: p.Namespace,
permission: p.Name,
type: p.Type,
resource: ""
)
);
}

await dbContext.SaveChangesAsync();
return guest.Id;
}
}

public class RbacInMemoryTestFixture
Expand Down Expand Up @@ -741,12 +769,8 @@ await rbacSvc.IsUserPermitted(
).Permitted()
);

/*
Reading public topics is allowed for everyone
This is currently handled outside of RBAC in the application logic
[04-11-2025, andfris] Leaving this test here as a reminder
*/
/*
// Reading public topics is allowed for everyone. This now comes from RBAC itself: topics/read-public
// is one of the Guest role's grants, and Guest is the baseline every user holds implicitly.
Assert.True(
(
await rbacSvc.IsUserPermitted(
Expand All @@ -756,7 +780,6 @@ await rbacSvc.IsUserPermitted(
)
).Permitted()
);
*/
}

private static RbacPermissionGrant UserGrant(RbacAccessType type, string resource) =>
Expand Down Expand Up @@ -893,4 +916,201 @@ await rbacSvc.IsUserPermitted("admin@dfds.cloud", [RbacCreate(RbacAccessType.Cap
).Permitted()
);
}

// ---------------------------------------------------------------------------------------------
// Guest baseline
// ---------------------------------------------------------------------------------------------

private const string Nobody = "nobody@dfds.cloud";

private static Permission CatalogueRead(RbacAccessType accessType) =>
new()
{
Namespace = RbacNamespace.ServiceCatalogue,
Name = "read",
AccessType = accessType,
};

private static Permission TopicsReadPublic(RbacAccessType accessType) =>
new()
{
Namespace = RbacNamespace.Topics,
Name = "read-public",
AccessType = accessType,
};

private static async Task<IRbacApplicationService> EmptyFixtureWithGuest(
params (RbacNamespace Namespace, string Name, RbacAccessType Type)[] guestPermissions
)
{
var fixture = await RbacTestData.NewInMemoryFixture(
true,
new List<RbacPermissionGrant>(),
new List<RbacRoleGrant>(),
new List<RbacGroup>()
);
await RbacTestData.SeedGuestRole(fixture.DbContext, guestPermissions);
return fixture.ApiApplication.Services.GetService<IRbacApplicationService>()!;
}

[Fact]
public async Task GuestBaselineSatisfiesGlobalScopedChecks()
{
var rbacSvc = await EmptyFixtureWithGuest((RbacNamespace.ServiceCatalogue, "read", RbacAccessType.Global));

// A Global-scoped controller has no {id} route value, so AuthChecker passes a null objectId.
Assert.True((await rbacSvc.IsUserPermitted(Nobody, [CatalogueRead(RbacAccessType.Global)], null!)).Permitted());
Assert.True((await rbacSvc.IsUserPermitted(Nobody, [CatalogueRead(RbacAccessType.Global)], "")).Permitted());
}

[Fact]
public async Task GuestBaselineAppliesToAnyCapability()
{
var rbacSvc = await EmptyFixtureWithGuest((RbacNamespace.Topics, "read-public", RbacAccessType.Capability));

Assert.True(
(await rbacSvc.IsUserPermitted(Nobody, [TopicsReadPublic(RbacAccessType.Capability)], "cap-a")).Permitted()
);
Assert.True(
(await rbacSvc.IsUserPermitted(Nobody, [TopicsReadPublic(RbacAccessType.Capability)], "cap-b")).Permitted()
);
}

[Fact]
public async Task GuestBaselineStillAppliesWhenUserHoldsACapabilityRole()
{
var readerRoleId = RbacRoleId.New();

var fixture = await RbacTestData.NewInMemoryFixture(
true,
new List<RbacPermissionGrant>
{
new(
id: RbacPermissionGrantId.New(),
createdAt: DateTime.Now,
assignedEntityType: AssignedEntityType.Role,
assignedEntityId: readerRoleId.ToString(),
@namespace: RbacNamespace.Topics,
permission: "read-private",
type: RbacAccessType.Global,
resource: ""
),
},
new List<RbacRoleGrant>
{
new(
id: RbacRoleGrantId.New(),
roleId: readerRoleId,
createdAt: DateTime.Now,
assignedEntityType: AssignedEntityType.User,
assignedEntityId: "reader@dfds.cloud",
type: RbacAccessType.Capability,
resource: "bar"
),
},
new List<RbacGroup>()
);
await RbacTestData.SeedGuestRole(
fixture.DbContext,
(RbacNamespace.Topics, "read-public", RbacAccessType.Capability)
);
var rbacSvc = fixture.ApiApplication.Services.GetService<IRbacApplicationService>()!;

Assert.True(
(
await rbacSvc.IsUserPermitted(
"reader@dfds.cloud",
[
new Permission
{
Namespace = RbacNamespace.Topics,
Name = "read-private",
AccessType = RbacAccessType.Capability,
},
],
"bar"
)
).Permitted()
);

Assert.True(
(
await rbacSvc.IsUserPermitted("reader@dfds.cloud", [TopicsReadPublic(RbacAccessType.Capability)], "bar")
).Permitted()
);
}

[Fact]
public async Task GuestBaselineIsAdditiveNeverSubtractive()
{
var fixture = await RbacTestData.NewInMemoryFixture(
true,
new List<RbacPermissionGrant> { UserGrant(RbacAccessType.Global, "") },
new List<RbacRoleGrant>(),
new List<RbacGroup>()
);
await RbacTestData.SeedGuestRole(
fixture.DbContext,
(RbacNamespace.Topics, "read-public", RbacAccessType.Capability)
);
var rbacSvc = fixture.ApiApplication.Services.GetService<IRbacApplicationService>()!;

var own = await rbacSvc.IsUserPermitted("test01@dfds.cloud", [RbacCreate(RbacAccessType.Global)], "test01");
Assert.True(own.Permitted());
Assert.NotEmpty(own.PermissionGrants);

var baseline = await rbacSvc.IsUserPermitted(
"test01@dfds.cloud",
[TopicsReadPublic(RbacAccessType.Capability)],
"test01"
);
Assert.True(baseline.Permitted());
Assert.NotEmpty(baseline.PermissionGrants);
}

[Fact]
public async Task CapabilityScopedGuestGrantDoesNotSatisfyGlobalCheck()
{
var rbacSvc = await EmptyFixtureWithGuest((RbacNamespace.Topics, "read-public", RbacAccessType.Capability));

Assert.False(
(await rbacSvc.IsUserPermitted(Nobody, [TopicsReadPublic(RbacAccessType.Global)], "cap-a")).Permitted()
);
}

[Fact]
public async Task GuestGrantScopeIgnoresTheStoredTypeColumn()
{
// what Guest reaches.
var storedGlobal = await EmptyFixtureWithGuest((RbacNamespace.ServiceCatalogue, "read", RbacAccessType.Global));
var storedCapability = await EmptyFixtureWithGuest(
(RbacNamespace.ServiceCatalogue, "read", RbacAccessType.Capability)
);

Assert.True(
(await storedGlobal.IsUserPermitted(Nobody, [CatalogueRead(RbacAccessType.Global)], null!)).Permitted()
);
Assert.True(
(await storedCapability.IsUserPermitted(Nobody, [CatalogueRead(RbacAccessType.Global)], null!)).Permitted()
);
}

[Fact]
public async Task NoGuestRoleMeansNoBaseline()
{
var fixture = await RbacTestData.NewInMemoryFixture(
true,
new List<RbacPermissionGrant>(),
new List<RbacRoleGrant>(),
new List<RbacGroup>()
);
var rbacSvc = fixture.ApiApplication.Services.GetService<IRbacApplicationService>()!;

Assert.False(
(await rbacSvc.IsUserPermitted(Nobody, [CatalogueRead(RbacAccessType.Global)], null!)).Permitted()
);
Assert.False(
(await rbacSvc.IsUserPermitted(Nobody, [TopicsReadPublic(RbacAccessType.Capability)], "cap-a")).Permitted()
);
}
}
43 changes: 43 additions & 0 deletions src/SelfService.Tests/Infrastructure/Api/TestRbacMeRoute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Net;
using System.Text.Json;
using SelfService.Domain.Models;
using SelfService.Tests.Application;

namespace SelfService.Tests.Infrastructure.Api;

public class TestRbacMeRoute
{
[Fact]
public async Task me_reports_the_guest_baseline_separately_from_the_users_own_grants()
{
var fixture = await RbacTestData.NewInMemoryFixture(
true,
new List<RbacPermissionGrant>(),
new List<RbacRoleGrant>(),
new List<RbacGroup>()
);
await RbacTestData.SeedGuestRole(
fixture.DbContext,
(RbacNamespace.ServiceCatalogue, "read", RbacAccessType.Global)
);

var application = fixture.ApiApplication;
await using var _ = application;
using var client = application.CreateClient();

var response = await client.GetAsync("/rbac/me");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = document.RootElement;

// The caller holds nothing of their own, but the baseline is still reported — /rbac/me would
// understate their effective access without it.
Assert.Empty(root.GetProperty("permissionGrants").EnumerateArray());

var baseline = root.GetProperty("baselinePermissionGrants").EnumerateArray().ToList();
var single = Assert.Single(baseline);
Assert.Equal("service-catalogue", single.GetProperty("namespace").GetString());
Assert.Equal("read", single.GetProperty("permission").GetString());
}
}
10 changes: 8 additions & 2 deletions src/SelfService.Tests/TestDoubles/StubPermissionQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ namespace SelfService.Tests.TestDoubles;

public class StubPermissionQuery : IPermissionQuery
{
public StubPermissionQuery() { }
private readonly List<RbacPermissionGrant> _guestPermissions;

// Defaults to an empty guest baseline so existing tests see no implicit permissions.
public StubPermissionQuery(List<RbacPermissionGrant>? guestPermissions = null)
{
_guestPermissions = guestPermissions ?? new List<RbacPermissionGrant>();
}

public Task<IList<RbacPermissionGrant>> FindUserGroupPermissionsByUserId(string userId)
{
Expand All @@ -19,6 +25,6 @@ public Task<IList<RbacRoleGrant>> FindUserGroupRolesByUserId(string userId)

public Task<List<RbacPermissionGrant>> FindGuestPermissions()
{
return Task.FromResult(new List<RbacPermissionGrant>());
return Task.FromResult(_guestPermissions);
}
}
Loading
Loading