Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ο»Ώusing Bit.Services.Pam.Api.Endpoints.Handlers;
ο»Ώusing Bit.Api.AdminConsole.Authorization;
using Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Services.Pam.Api.Endpoints.Handlers;
using Bit.Services.Pam.Api.Models.Request;

namespace Bit.Services.Pam.Api.Endpoints;
Expand All @@ -7,11 +9,26 @@ namespace Bit.Services.Pam.Api.Endpoints;
/// The <c>organizations/{orgId}/access-rules</c> resource: rule CRUD scoped to an organization. <c>orgId</c> is
/// bound from the group's route prefix.
/// </summary>
/// <remarks>
/// Authorization runs in the middleware rather than the handler. The group requires organization membership and the
/// write endpoints additionally require <see cref="ManageAccessRulesRequirement"/>; ASP.NET combines the group and
/// endpoint policies, so a write has to satisfy both. Both requirements read <c>orgId</c> off the route, which the
/// group prefix supplies.
/// <para>
/// The group requirement is deliberately <see cref="MemberRequirement"/> and not
/// <c>MemberOrProviderRequirement</c>: providers manage an organization's billing and configuration, but access
/// rules gate who can lease credentials out of it, which is not theirs to change. Note this group gate is the only
/// thing keeping providers out β€” <see cref="ManageAccessRulesRequirement"/> derives from
/// <c>BasePermissionRequirement</c>, which falls back to authorizing a provider for the organization. Removing or
/// weakening the group requirement would silently readmit them to the write endpoints.

@eliykat eliykat Aug 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suggests that you don't actually want to reuse BasePermissionRequirement because you don't want to authorize providers. It works for most of our custom permissions, but you can always implement IOrganizationRequirement directly if you don't want that behavior.

For example:

public class ManageAccessRulesRequirement : IOrganizationRequirement
{
    public Task<bool> AuthorizeAsync(CurrentContextOrganization? organizationClaims,
        Func<Task<bool>> isProviderUserForOrg)
    {
        var authorized = organizationClaims is
            { Type: OrganizationUserType.Owner }
            or { Type: OrganizationUserType.Admin }
            or { Type: OrganizationUserType.Custom, Permissions.ManageAccessRules : true };

        return Task.FromResult(authorized);
    }
}

This can be used inside Authorize<T> in the same way, so no change to the consumers.

You can own this requirement if desired, because it reflects the authorization rules for your domain.

/// </para>
/// </remarks>
internal static class AccessRuleEndpoints
{
public static RouteGroupBuilder MapAccessRuleEndpoints(this RouteGroupBuilder group)
{
group.WithTags("AccessRules");
group.RequireAuthorization(new AuthorizeAttribute<MemberRequirement>());

group.MapGet("", (Guid orgId, AccessRuleEndpointsHandler handler) => handler.GetAll(orgId))
.WithName("Pam_AccessRules_GetAll");
Expand All @@ -20,18 +37,21 @@ public static RouteGroupBuilder MapAccessRuleEndpoints(this RouteGroupBuilder gr
.WithName("Pam_AccessRules_Get");

group.MapPost("", (Guid orgId, AccessRuleRequestModel model, AccessRuleEndpointsHandler handler) => handler.Post(orgId, model))
.WithName("Pam_AccessRules_Post");
.WithName("Pam_AccessRules_Post")
.RequireAuthorization(new AuthorizeAttribute<ManageAccessRulesRequirement>());

group.MapPut("{id:guid}", (Guid orgId, Guid id, AccessRuleRequestModel model, AccessRuleEndpointsHandler handler) => handler.Put(orgId, id, model))
.WithName("Pam_AccessRules_Put");
.WithName("Pam_AccessRules_Put")
.RequireAuthorization(new AuthorizeAttribute<ManageAccessRulesRequirement>());

group.MapDelete("{id:guid}",
async (Guid orgId, Guid id, AccessRuleEndpointsHandler handler) =>
{
await handler.Delete(orgId, id);
return TypedResults.NoContent();
})
.WithName("Pam_AccessRules_Delete");
.WithName("Pam_AccessRules_Delete")
.RequireAuthorization(new AuthorizeAttribute<ManageAccessRulesRequirement>());

return group;
}
Expand Down
1 change: 1 addition & 0 deletions bitwarden_license/src/Services/Pam/Pam.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Core\Core.csproj" />
<ProjectReference Include="..\..\..\..\src\HttpExtensions\HttpExtensions.csproj" />
<ProjectReference Include="..\..\..\..\src\Libraries\OrganizationAuthorization\OrganizationAuthorization.csproj" />
</ItemGroup>

</Project>
6 changes: 6 additions & 0 deletions bitwarden_license/src/Services/Pam/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,12 @@
"httpextensions": {
"type": "Project"
},
"organizationauthorization": {
"type": "Project",
"dependencies": {
"Core": "[2026.7.2, )"
}
},
"serilogfilelogging": {
"type": "Project",
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
ο»Ώusing Bit.Core.Models.Api;
ο»Ώusing Bit.Api.AdminConsole.Authorization.Requirements;
using Bit.Core.Auth.Identity;
using Bit.Core.Models.Api;
using Bit.HttpExtensions;
using Bit.Services.Pam.Api.Endpoints;
using Bit.Services.Pam.Api.Endpoints.Handlers;
using Bit.Services.Pam.Api.Models.Response;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
Expand Down Expand Up @@ -84,6 +87,79 @@ public void AccessRuleGroup_DocumentsErrorResponseModel_For400And404()
Assert.Contains(produces, p => p.StatusCode == StatusCodes.Status404NotFound && p.Type == typeof(ErrorResponseModel));
}

/// <summary>
/// Collects the authorization requirements an endpoint carries. They arrive as two shapes of metadata:
/// <c>AuthorizeAttribute&lt;T&gt;</c> contributes <see cref="IAuthorizationRequirementData"/>, while a policy
/// built inline contributes an <see cref="AuthorizationPolicy"/>. AuthorizationMiddleware combines both, so a
/// test asking "what must this endpoint satisfy" has to read both.
/// </summary>
private static List<IAuthorizationRequirement> RequirementsFor(Endpoint endpoint) =>
[
.. endpoint.Metadata.GetOrderedMetadata<AuthorizationPolicy>().SelectMany(policy => policy.Requirements),
.. endpoint.Metadata.GetOrderedMetadata<IAuthorizationRequirementData>().SelectMany(data => data.GetRequirements())
];

[Theory]
[InlineData("Pam_AccessRules_GetAll", typeof(MemberRequirement))]
[InlineData("Pam_AccessRules_Get", typeof(MemberRequirement))]
[InlineData("Pam_AccessRules_Post", typeof(ManageAccessRulesRequirement))]
[InlineData("Pam_AccessRules_Put", typeof(ManageAccessRulesRequirement))]
[InlineData("Pam_AccessRules_Delete", typeof(ManageAccessRulesRequirement))]
public void MapPamEndpoints_AuthorizesRouteWithRequirement(string name, Type requirementType)
{
// Reads require membership; writes require authority over rule authorship. The requirements are carried as
// endpoint metadata, which AuthorizationMiddleware combines with the group's Policies.Application.
var endpoint = Assert.Single(
MaterializeEndpoints(),
e => e.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == name);

Assert.Contains(RequirementsFor(endpoint), r => r.GetType() == requirementType);

// The per-route requirement adds to the group's policy rather than replacing it.
Assert.Contains(endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>(),
data => data.Policy == Policies.Application);
}

[Fact]
public void MapPamEndpoints_AccessRuleWritesRequireMembershipBesidesThePermission()
{
// ManageAccessRulesRequirement derives from BasePermissionRequirement, whose final arm authorizes any
// provider for the organization. The group's MemberRequirement is the only thing keeping providers out of
// rule authorship, so each write must carry it *in addition to* the permission β€” the permission alone would
// admit them.
var writeRoutes = new[] { "Pam_AccessRules_Post", "Pam_AccessRules_Put", "Pam_AccessRules_Delete" };

var endpoints = MaterializeEndpoints()
.Where(e => writeRoutes.Contains(e.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName))
.ToList();

Assert.Equal(writeRoutes.Length, endpoints.Count);
Assert.All(endpoints, endpoint =>
{
var requirements = RequirementsFor(endpoint);
Assert.Contains(requirements, r => r is MemberRequirement);
Assert.Contains(requirements, r => r is ManageAccessRulesRequirement);
});
}

[Fact]
public void MapPamEndpoints_AccessRulesNeverAuthorizeProvidersByMembership()
{
// Access rules gate who can lease credentials out of an organization, which is not a provider's to read or
// change. MemberOrProviderRequirement would let them in, so no access-rule route may carry it.
var endpoints = MaterializeEndpoints()
.Where(e => e.Metadata.GetMetadata<ITagsMetadata>()!.Tags.Contains("AccessRules"))
.ToList();

Assert.Equal(5, endpoints.Count);
Assert.All(endpoints, endpoint =>
{
var requirements = RequirementsFor(endpoint);
Assert.Contains(requirements, r => r is MemberRequirement);
Assert.DoesNotContain(requirements, r => r is MemberOrProviderRequirement);
});
}

[Theory]
[InlineData(nameof(AccessRuleEndpointsHandler.GetAll), typeof(Task<ListResponseModel<AccessRuleResponseModel>>))]
[InlineData(nameof(AccessRuleEndpointsHandler.Get), typeof(Task<AccessRuleResponseModel>))]
Expand Down
9 changes: 8 additions & 1 deletion bitwarden_license/test/Services/Pam.Test/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1323,11 +1323,18 @@
"httpextensions": {
"type": "Project"
},
"organizationauthorization": {
"type": "Project",
"dependencies": {
"Core": "[2026.7.2, )"
}
},
"pam": {
"type": "Project",
"dependencies": {
"Core": "[2026.7.2, )",
"HttpExtensions": "[2026.7.2, )"
"HttpExtensions": "[2026.7.2, )",
"OrganizationAuthorization": "[0.0.1, )"
}
},
"serilogfilelogging": {
Expand Down
3 changes: 2 additions & 1 deletion src/Api/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1276,7 +1276,8 @@
"type": "Project",
"dependencies": {
"Core": "[2026.7.2, )",
"HttpExtensions": "[2026.7.2, )"
"HttpExtensions": "[2026.7.2, )",
"OrganizationAuthorization": "[0.0.1, )"
}
},
"serilogfilelogging": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
public class AccessEventLogsRequirement() : BasePermissionRequirement(p => p.AccessEventLogs);
public class AccessImportExportRequirement() : BasePermissionRequirement(p => p.AccessImportExport);
public class AccessReportsRequirement() : BasePermissionRequirement(p => p.AccessReports);
public class ManageAccessRulesRequirement() : BasePermissionRequirement(p => p.ManageAccessRules);
public class ManageAccountRecoveryRequirement() : BasePermissionRequirement(p => p.ManageResetPassword);
public class ManageGroupsRequirement() : BasePermissionRequirement(p => p.ManageGroups);
public class ManagePoliciesRequirement() : BasePermissionRequirement(p => p.ManagePolicies);
Expand Down
3 changes: 2 additions & 1 deletion test/Api.IntegrationTest/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1554,7 +1554,8 @@
"type": "Project",
"dependencies": {
"Core": "[2026.7.2, )",
"HttpExtensions": "[2026.7.2, )"
"HttpExtensions": "[2026.7.2, )",
"OrganizationAuthorization": "[0.0.1, )"
}
},
"rustsdk": {
Expand Down
3 changes: 2 additions & 1 deletion test/Api.Test/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -1892,7 +1892,8 @@
"type": "Project",
"dependencies": {
"Core": "[2026.7.2, )",
"HttpExtensions": "[2026.7.2, )"
"HttpExtensions": "[2026.7.2, )",
"OrganizationAuthorization": "[0.0.1, )"
}
},
"serilogfilelogging": {
Expand Down
3 changes: 2 additions & 1 deletion test/Billing.IntegrationTest/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -2099,7 +2099,8 @@
"type": "Project",
"dependencies": {
"Core": "[2026.7.2, )",
"HttpExtensions": "[2026.7.2, )"
"HttpExtensions": "[2026.7.2, )",
"OrganizationAuthorization": "[0.0.1, )"
}
},
"postgresmigrations": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public class PermissionRequirementsTests
new object[] { new AccessEventLogsRequirement(), nameof(Permissions.AccessEventLogs) },
new object[] { new AccessImportExportRequirement(), nameof(Permissions.AccessImportExport) },
new object[] { new AccessReportsRequirement(), nameof(Permissions.AccessReports) },
new object[] { new ManageAccessRulesRequirement(), nameof(Permissions.ManageAccessRules) },
new object[] { new ManageAccountRecoveryRequirement(), nameof(Permissions.ManageResetPassword) },
new object[] { new ManageGroupsRequirement(), nameof(Permissions.ManageGroups) },
new object[] { new ManagePoliciesRequirement(), nameof(Permissions.ManagePolicies) },
Expand Down
Loading