Add fine-grained permissions for settings and stats endpoints
- Add provisioning.settings:read action (granted to Viewer role) - Add provisioning.stats:read action (granted to Admin role) - Add accessWithViewer to APIBuilder for Viewer role fallback - Use access checker for settings/stats authorization - Remove role-based authorization functions (isRoleBasedResource, authorizeRoleBasedResource) This makes settings and stats consistent with other provisioning resources and works properly in both ST and MT modes via the access checker.
This commit is contained in:
@@ -26,6 +26,12 @@ const (
|
||||
|
||||
// Historic Jobs
|
||||
ActionProvisioningHistoricJobsRead = "provisioning.historicjobs:read" // GET + LIST.
|
||||
|
||||
// Settings (read-only, needed by multiple UI pages)
|
||||
ActionProvisioningSettingsRead = "provisioning.settings:read" // GET + LIST.
|
||||
|
||||
// Stats (read-only, admin-only)
|
||||
ActionProvisioningStatsRead = "provisioning.stats:read" // GET + LIST.
|
||||
)
|
||||
|
||||
func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
@@ -165,6 +171,38 @@ func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
// Settings - granted to Viewer (accessible by all logged-in users)
|
||||
settingsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.settings:reader",
|
||||
DisplayName: "Settings Reader",
|
||||
Description: "Read provisioning settings.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningSettingsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleViewer)},
|
||||
}
|
||||
|
||||
// Stats - granted to Admin only
|
||||
statsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.stats:reader",
|
||||
DisplayName: "Stats Reader",
|
||||
Description: "Read provisioning stats.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningStatsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
return service.DeclareFixedRoles(
|
||||
repositoriesReader,
|
||||
repositoriesWriter,
|
||||
@@ -173,5 +211,7 @@ func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
jobsReader,
|
||||
jobsWriter,
|
||||
historicJobsReader,
|
||||
settingsReader,
|
||||
statsReader,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ type APIBuilder struct {
|
||||
access auth.AccessChecker
|
||||
accessWithAdmin auth.AccessChecker
|
||||
accessWithEditor auth.AccessChecker
|
||||
accessWithViewer auth.AccessChecker
|
||||
statusPatcher *appcontroller.RepositoryStatusPatcher
|
||||
healthChecker *controller.HealthChecker
|
||||
validator repository.RepositoryValidator
|
||||
@@ -184,6 +185,7 @@ func NewAPIBuilder(
|
||||
access: accessChecker,
|
||||
accessWithAdmin: accessChecker.WithFallbackRole(identity.RoleAdmin),
|
||||
accessWithEditor: accessChecker.WithFallbackRole(identity.RoleEditor),
|
||||
accessWithViewer: accessChecker.WithFallbackRole(identity.RoleViewer),
|
||||
jobHistoryConfig: jobHistoryConfig,
|
||||
extraWorkers: extraWorkers,
|
||||
restConfigGetter: restConfigGetter,
|
||||
@@ -311,16 +313,6 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle role-based resources that bypass the access checker.
|
||||
// These resources don't have fine-grained permissions registered.
|
||||
if isRoleBasedResource(a.GetResource()) {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "failed to find requester", err
|
||||
}
|
||||
return authorizeRoleBasedResource(ctx, a.GetResource(), id)
|
||||
}
|
||||
|
||||
return b.authorizeResource(ctx, a)
|
||||
})
|
||||
}
|
||||
@@ -345,10 +337,10 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
// - Read-only: historicjobs:read
|
||||
//
|
||||
// Settings:
|
||||
// - Viewer role (read-only, needed by multiple UI pages)
|
||||
// - settings:read - granted to Viewer (all logged-in users)
|
||||
//
|
||||
// Stats:
|
||||
// - Admin role required
|
||||
// - stats:read - granted to Admin only
|
||||
func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
switch a.GetResource() {
|
||||
case provisioning.RepositoryResourceInfo.GetName():
|
||||
@@ -372,12 +364,22 @@ func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attribu
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
case "settings", "stats":
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "failed to find requester", err
|
||||
}
|
||||
return authorizeRoleBasedResource(ctx, a.GetResource(), id)
|
||||
case "settings":
|
||||
// Settings are read-only and accessible by all logged-in users (Viewer role)
|
||||
return toAuthorizerDecision(b.accessWithViewer.Check(ctx, authlib.CheckRequest{
|
||||
Verb: apiutils.VerbGet,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: "settings",
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
case "stats":
|
||||
// Stats are read-only and admin-only
|
||||
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: apiutils.VerbGet,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: "stats",
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
default:
|
||||
return b.authorizeDefault(ctx)
|
||||
}
|
||||
@@ -502,42 +504,6 @@ func toAuthorizerDecision(err error) (authorizer.Decision, string, error) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
// isRoleBasedResource returns true for resources that use role-based authorization
|
||||
// instead of fine-grained permissions. These resources bypass the access checker.
|
||||
func isRoleBasedResource(resource string) bool {
|
||||
return resource == "settings" || resource == "stats"
|
||||
}
|
||||
|
||||
// authorizeRoleBasedResource handles authorization for resources without fine-grained permissions.
|
||||
// - settings: viewer role (read-only, needed by multiple UI pages)
|
||||
// - stats: admin role
|
||||
// Also checks authlib.AuthInfoFrom(ctx) for AccessPolicy identity type in MT mode.
|
||||
func authorizeRoleBasedResource(ctx context.Context, resource string, id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// Check AccessPolicy identity type from both sources (ST and MT modes)
|
||||
isAccessPolicy := authlib.IsIdentityType(id.GetIdentityType(), authlib.TypeAccessPolicy)
|
||||
if !isAccessPolicy {
|
||||
// Also check authlib.AuthInfoFrom for MT mode
|
||||
if authInfo, ok := authlib.AuthInfoFrom(ctx); ok {
|
||||
isAccessPolicy = authlib.IsIdentityType(authInfo.GetIdentityType(), authlib.TypeAccessPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
switch resource {
|
||||
case "settings":
|
||||
if isAccessPolicy || id.GetOrgRole().Includes(identity.RoleViewer) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "viewer role is required", nil
|
||||
case "stats":
|
||||
if isAccessPolicy || id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "admin role is required", nil
|
||||
default:
|
||||
return authorizer.DecisionDeny, "unknown role-based resource", nil
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeDefault handles authorization for unmapped resources.
|
||||
func (b *APIBuilder) authorizeDefault(ctx context.Context) (authorizer.Decision, string, error) {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
|
||||
Reference in New Issue
Block a user