fix(authz): enforce Atom list checks for rules reports and alarms

Signed-off-by: Arvindh <arvindh91@gmail.com>
This commit is contained in:
Arvindh
2026-06-19 19:48:52 +05:30
parent 4cc2f297d7
commit 2978dc7716
12 changed files with 342 additions and 6 deletions
+3
View File
@@ -120,6 +120,9 @@ func (am *authorizationMiddleware) ListAlarms(ctx context.Context, session authn
case err == nil:
session.SuperAdmin = true
case errors.Contains(err, svcerr.ErrSuperAdminAction):
if err := am.authorize(ctx, operations.OpListAlarms, session, operations.EntityType, auth.AnyIDs); err != nil {
return alarms.AlarmsPage{}, errors.Wrap(errDomainViewAlarms, err)
}
default:
return alarms.AlarmsPage{}, err
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"testing"
"github.com/absmach/magistrala/alarms"
"github.com/absmach/magistrala/alarms/mocks"
"github.com/absmach/magistrala/alarms/operations"
"github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
pkgerrors "github.com/absmach/magistrala/pkg/errors"
"github.com/absmach/magistrala/pkg/permissions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type recordingAtomAuthorizer struct {
allowed bool
reqs []atom.AuthzRequest
}
func (a *recordingAtomAuthorizer) CheckAuthz(_ context.Context, req atom.AuthzRequest) (atom.AuthzResponse, error) {
a.reqs = append(a.reqs, req)
return atom.AuthzResponse{Allowed: a.allowed}, nil
}
func TestListAlarmsAuthorizesRegularUser(t *testing.T) {
svc := mocks.NewService(t)
pm := alarms.PageMetadata{Limit: 10}
expectedPM := pm
expectedPM.DomainID = "domain-1"
session := authn.Session{UserID: "user-1", DomainID: "domain-1", DomainUserID: "domain-1_user-1"}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := NewAtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListAlarms", mock.Anything, session, expectedPM).Return(alarms.AlarmsPage{Limit: 10}, nil).Once()
page, err := wrapped.ListAlarms(context.Background(), session, pm)
require.NoError(t, err)
assert.Equal(t, uint64(10), page.Limit)
require.Len(t, authz.reqs, 1)
assert.Equal(t, atom.AuthzRequest{
SubjectID: "user-1",
Action: "list",
ResourceID: auth.AnyIDs,
ObjectKind: "resource",
ObjectID: auth.AnyIDs,
Context: map[string]any{
"domain_id": "domain-1",
"legacy_object_type": operations.EntityType,
},
}, authz.reqs[0])
}
func TestListAlarmsDeniedRegularUserDoesNotDelegate(t *testing.T) {
svc := mocks.NewService(t)
authz := &recordingAtomAuthorizer{allowed: false}
wrapped, err := NewAtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
_, err = wrapped.ListAlarms(context.Background(), authn.Session{UserID: "user-1", DomainID: "domain-1"}, alarms.PageMetadata{})
assert.True(t, pkgerrors.Contains(err, pkgerrors.ErrAuthorization))
require.Len(t, authz.reqs, 1)
}
func TestListAlarmsSuperAdminSkipsListAuthorization(t *testing.T) {
svc := mocks.NewService(t)
pm := alarms.PageMetadata{Limit: 10}
expectedPM := pm
expectedPM.DomainID = "domain-1"
session := authn.Session{UserID: "admin-1", DomainID: "domain-1", Role: authn.SuperAdminRole}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := NewAtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListAlarms", mock.Anything, mock.MatchedBy(func(s authn.Session) bool {
return s.SuperAdmin
}), expectedPM).Return(alarms.AlarmsPage{Limit: 10}, nil).Once()
_, err = wrapped.ListAlarms(context.Background(), session, pm)
require.NoError(t, err)
require.Len(t, authz.reqs, 1)
assert.Equal(t, "manage", authz.reqs[0].Action)
}
func testEntitiesOps(t *testing.T) permissions.EntitiesOperations[permissions.Operation] {
t.Helper()
details := operations.OperationDetails()
perms := make(map[string]permissions.Permission, len(details))
for _, detail := range details {
if detail.PermissionRequired {
perms[detail.Name] = permissions.Permission(detail.Name)
}
}
entitiesOps, err := permissions.NewEntitiesOperations(
permissions.EntitiesPermission{operations.EntityType: perms},
permissions.EntitiesOperationDetails[permissions.Operation]{operations.EntityType: details},
)
require.NoError(t, err)
return entitiesOps
}
+2 -2
View File
@@ -84,8 +84,8 @@ func CapabilityName(action string) string {
return atomActionSubscribe
case normalized == "generate", normalized == "execute":
return atomActionExecute
case normalized == "list":
return "list"
case normalized == atomActionList:
return atomActionList
default:
return normalized
}
+4
View File
@@ -16,6 +16,7 @@ var magistralaActionDescriptions = map[string]string{
atomActionPublish: "Publish messages to a channel",
atomActionSubscribe: "Subscribe to channel messages",
atomActionExecute: "Execute a command or action",
atomActionList: "List objects",
}
var magistralaActionApplicability = []CapabilityApplicabilitySpec{
@@ -31,17 +32,20 @@ var magistralaActionApplicability = []CapabilityApplicabilitySpec{
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionExecute, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionList, ObjectKind: atomObjectKindResource, ObjectType: "resource:rule"},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionExecute, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionList, ObjectKind: atomObjectKindResource, ObjectType: "resource:report"},
{ActionName: atomActionRead, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
{ActionName: atomActionWrite, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
{ActionName: atomActionDelete, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
{ActionName: atomActionManage, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
{ActionName: atomActionList, ObjectKind: atomObjectKindResource, ObjectType: "resource:alarm"},
}
var magistralaActionAssignmentRules = []ActionAssignmentRuleSpec{
+4 -1
View File
@@ -103,7 +103,7 @@ func TestBootstrapMagistralaActionsCreatesMissingActionsAndApplicability(t *test
t.Fatalf("bootstrap failed: %v", err)
}
for _, name := range []string{atomActionRead, atomActionWrite, atomActionDelete, atomActionManage, atomActionPublish, atomActionSubscribe, atomActionExecute} {
for _, name := range []string{atomActionRead, atomActionWrite, atomActionDelete, atomActionManage, atomActionPublish, atomActionSubscribe, atomActionExecute, atomActionList} {
if _, ok := actions[name]; !ok {
t.Fatalf("action %q was not ensured", name)
}
@@ -113,8 +113,11 @@ func TestBootstrapMagistralaActionsCreatesMissingActionsAndApplicability(t *test
}
assertApplicability(t, applicability, "publish-id", "resource:channel")
assertApplicability(t, applicability, "execute-id", "resource:rule")
assertApplicability(t, applicability, "list-id", "resource:rule")
assertApplicability(t, applicability, "execute-id", "resource:report")
assertApplicability(t, applicability, "list-id", "resource:report")
assertApplicability(t, applicability, "manage-id", "resource:alarm")
assertApplicability(t, applicability, "list-id", "resource:alarm")
if len(assignmentRules) != len(magistralaActionAssignmentRules) {
t.Fatalf("unexpected assignment guardrail count: got %d want %d", len(assignmentRules), len(magistralaActionAssignmentRules))
}
+1
View File
@@ -11,6 +11,7 @@ const (
atomActionPublish = "publish"
atomActionSubscribe = "subscribe"
atomActionExecute = "execute"
atomActionList = "list"
)
const (
+2 -2
View File
@@ -50,9 +50,9 @@ func policySubjectID(pr policies.Policy) string {
func policyAction(pr policies.Policy) string {
if pr.Permission != "" {
return pr.Permission
return CapabilityName(pr.Permission)
}
return pr.Relation
return CapabilityName(pr.Relation)
}
func policyObjectKind(pr policies.Policy) string {
+1 -1
View File
@@ -28,7 +28,7 @@ func TestPolicyEvaluatorCheckPolicy(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, atom.AuthzRequest{
SubjectID: "user-1",
Action: policies.ViewPermission,
Action: "read",
ResourceID: "rule-1",
ObjectKind: "resource",
ObjectID: "rule-1",
+3
View File
@@ -101,6 +101,9 @@ func (am *authorizationMiddleware) ListRules(ctx context.Context, session authn.
case err == nil:
session.SuperAdmin = true
case errors.Contains(err, svcerr.ErrSuperAdminAction):
if err := am.authorize(ctx, operations.OpListRules, session, operations.EntityType, auth.AnyIDs); err != nil {
return re.Page{}, errors.Wrap(errDomainViewRules, err)
}
default:
return re.Page{}, err
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"testing"
"github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
pkgerrors "github.com/absmach/magistrala/pkg/errors"
"github.com/absmach/magistrala/pkg/permissions"
"github.com/absmach/magistrala/re"
"github.com/absmach/magistrala/re/mocks"
"github.com/absmach/magistrala/re/operations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type recordingAtomAuthorizer struct {
allowed bool
reqs []atom.AuthzRequest
}
func (a *recordingAtomAuthorizer) CheckAuthz(_ context.Context, req atom.AuthzRequest) (atom.AuthzResponse, error) {
a.reqs = append(a.reqs, req)
return atom.AuthzResponse{Allowed: a.allowed}, nil
}
func TestListRulesAuthorizesRegularUser(t *testing.T) {
svc := mocks.NewService(t)
pm := re.PageMeta{Limit: 10}
session := authn.Session{UserID: "user-1", DomainID: "domain-1", DomainUserID: "domain-1_user-1"}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := AtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListRules", mock.Anything, session, pm).Return(re.Page{Limit: 10}, nil).Once()
page, err := wrapped.ListRules(context.Background(), session, pm)
require.NoError(t, err)
assert.Equal(t, uint64(10), page.Limit)
require.Len(t, authz.reqs, 1)
assert.Equal(t, atom.AuthzRequest{
SubjectID: "user-1",
Action: "list",
ResourceID: auth.AnyIDs,
ObjectKind: "resource",
ObjectID: auth.AnyIDs,
Context: map[string]any{
"domain_id": "domain-1",
"legacy_object_type": operations.EntityType,
},
}, authz.reqs[0])
}
func TestListRulesDeniedRegularUserDoesNotDelegate(t *testing.T) {
svc := mocks.NewService(t)
authz := &recordingAtomAuthorizer{allowed: false}
wrapped, err := AtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
_, err = wrapped.ListRules(context.Background(), authn.Session{UserID: "user-1", DomainID: "domain-1"}, re.PageMeta{})
assert.True(t, pkgerrors.Contains(err, pkgerrors.ErrAuthorization))
require.Len(t, authz.reqs, 1)
}
func TestListRulesSuperAdminSkipsListAuthorization(t *testing.T) {
svc := mocks.NewService(t)
pm := re.PageMeta{Limit: 10}
session := authn.Session{UserID: "admin-1", DomainID: "domain-1", Role: authn.SuperAdminRole}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := AtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListRules", mock.Anything, mock.MatchedBy(func(s authn.Session) bool {
return s.SuperAdmin
}), pm).Return(re.Page{Limit: 10}, nil).Once()
_, err = wrapped.ListRules(context.Background(), session, pm)
require.NoError(t, err)
require.Len(t, authz.reqs, 1)
assert.Equal(t, "manage", authz.reqs[0].Action)
}
func testEntitiesOps(t *testing.T) permissions.EntitiesOperations[permissions.Operation] {
t.Helper()
details := operations.OperationDetails()
perms := make(map[string]permissions.Permission, len(details))
for _, detail := range details {
if detail.PermissionRequired {
perms[detail.Name] = permissions.Permission(detail.Name)
}
}
entitiesOps, err := permissions.NewEntitiesOperations(
permissions.EntitiesPermission{operations.EntityType: perms},
permissions.EntitiesOperationDetails[permissions.Operation]{operations.EntityType: details},
)
require.NoError(t, err)
return entitiesOps
}
+3
View File
@@ -105,6 +105,9 @@ func (am *authorizationMiddleware) ListReportsConfig(ctx context.Context, sessio
case err == nil:
session.SuperAdmin = true
case errors.Contains(err, svcerr.ErrSuperAdminAction):
if err := am.authorize(ctx, operations.OpListReportsConfig, session, operations.EntityType, auth.AnyIDs); err != nil {
return reports.ReportConfigPage{}, errors.Wrap(errDomainViewConfigs, err)
}
default:
return reports.ReportConfigPage{}, err
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright (c) Abstract Machines
// SPDX-License-Identifier: Apache-2.0
package middleware
import (
"context"
"testing"
"github.com/absmach/magistrala/auth"
"github.com/absmach/magistrala/internal/atom"
"github.com/absmach/magistrala/pkg/authn"
pkgerrors "github.com/absmach/magistrala/pkg/errors"
"github.com/absmach/magistrala/pkg/permissions"
"github.com/absmach/magistrala/reports"
"github.com/absmach/magistrala/reports/mocks"
"github.com/absmach/magistrala/reports/operations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type recordingAtomAuthorizer struct {
allowed bool
reqs []atom.AuthzRequest
}
func (a *recordingAtomAuthorizer) CheckAuthz(_ context.Context, req atom.AuthzRequest) (atom.AuthzResponse, error) {
a.reqs = append(a.reqs, req)
return atom.AuthzResponse{Allowed: a.allowed}, nil
}
func TestListReportsConfigAuthorizesRegularUser(t *testing.T) {
svc := mocks.NewService(t)
pm := reports.PageMeta{Limit: 10}
session := authn.Session{UserID: "user-1", DomainID: "domain-1", DomainUserID: "domain-1_user-1"}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := AtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListReportsConfig", mock.Anything, session, pm).Return(reports.ReportConfigPage{PageMeta: reports.PageMeta{Limit: 10}}, nil).Once()
page, err := wrapped.ListReportsConfig(context.Background(), session, pm)
require.NoError(t, err)
assert.Equal(t, uint64(10), page.Limit)
require.Len(t, authz.reqs, 1)
assert.Equal(t, atom.AuthzRequest{
SubjectID: "user-1",
Action: "list",
ResourceID: auth.AnyIDs,
ObjectKind: "resource",
ObjectID: auth.AnyIDs,
Context: map[string]any{
"domain_id": "domain-1",
"legacy_object_type": operations.EntityType,
},
}, authz.reqs[0])
}
func TestListReportsConfigDeniedRegularUserDoesNotDelegate(t *testing.T) {
svc := mocks.NewService(t)
authz := &recordingAtomAuthorizer{allowed: false}
wrapped, err := AtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
_, err = wrapped.ListReportsConfig(context.Background(), authn.Session{UserID: "user-1", DomainID: "domain-1"}, reports.PageMeta{})
assert.True(t, pkgerrors.Contains(err, pkgerrors.ErrAuthorization))
require.Len(t, authz.reqs, 1)
}
func TestListReportsConfigSuperAdminSkipsListAuthorization(t *testing.T) {
svc := mocks.NewService(t)
pm := reports.PageMeta{Limit: 10}
session := authn.Session{UserID: "admin-1", DomainID: "domain-1", Role: authn.SuperAdminRole}
authz := &recordingAtomAuthorizer{allowed: true}
wrapped, err := AtomAuthorizationMiddleware(svc, authz, testEntitiesOps(t))
require.NoError(t, err)
svc.On("ListReportsConfig", mock.Anything, mock.MatchedBy(func(s authn.Session) bool {
return s.SuperAdmin
}), pm).Return(reports.ReportConfigPage{PageMeta: reports.PageMeta{Limit: 10}}, nil).Once()
_, err = wrapped.ListReportsConfig(context.Background(), session, pm)
require.NoError(t, err)
require.Len(t, authz.reqs, 1)
assert.Equal(t, "manage", authz.reqs[0].Action)
}
func testEntitiesOps(t *testing.T) permissions.EntitiesOperations[permissions.Operation] {
t.Helper()
details := operations.OperationDetails()
perms := make(map[string]permissions.Permission, len(details))
for _, detail := range details {
if detail.PermissionRequired {
perms[detail.Name] = permissions.Permission(detail.Name)
}
}
entitiesOps, err := permissions.NewEntitiesOperations(
permissions.EntitiesPermission{operations.EntityType: perms},
permissions.EntitiesOperationDetails[permissions.Operation]{operations.EntityType: details},
)
require.NoError(t, err)
return entitiesOps
}