The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
1065 lines
30 KiB
Go
1065 lines
30 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package iam_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.gearno.de/kit/log"
|
|
"go.gearno.de/kit/pg"
|
|
"go.probo.inc/probo/internal/test"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/gid"
|
|
"go.probo.inc/probo/pkg/iam"
|
|
"go.probo.inc/probo/pkg/iam/oauth2scope"
|
|
"go.probo.inc/probo/pkg/iam/policy"
|
|
"go.probo.inc/probo/pkg/mail"
|
|
)
|
|
|
|
type batchAuthorizeFixture struct {
|
|
tenantID gid.TenantID
|
|
identityID gid.GID
|
|
membershipID gid.GID
|
|
organizationID gid.GID
|
|
organization2ID gid.GID
|
|
frameworkID1 gid.GID
|
|
frameworkID2 gid.GID
|
|
frameworkID3 gid.GID
|
|
}
|
|
|
|
func TestAuthorizer_AuthorizeBatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("happy path", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
|
|
scope, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.frameworkID2,
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, scope)
|
|
assert.Equal(t, fixture.tenantID, scope.GetTenantID())
|
|
assert.Equal(t, 2, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("mixed organization batch", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
|
|
_, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.frameworkID3,
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
errMixedOrg, ok := err.(*iam.ErrMixedOrganizationBatch)
|
|
require.True(t, ok)
|
|
assert.ElementsMatch(
|
|
t,
|
|
[]string{
|
|
fixture.organizationID.String(),
|
|
fixture.organization2ID.String(),
|
|
},
|
|
errMixedOrg.OrganizationIDs,
|
|
)
|
|
})
|
|
|
|
t.Run("mixed entity type batch", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
|
|
_, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.organizationID,
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
errMixedEntityType, ok := err.(*iam.ErrMixedEntityTypeBatch)
|
|
require.True(t, ok)
|
|
assert.Equal(
|
|
t,
|
|
[]uint16{coredata.OrganizationEntityType, coredata.FrameworkEntityType},
|
|
errMixedEntityType.EntityTypes,
|
|
)
|
|
})
|
|
|
|
t.Run("unsupported resource type for batch attributes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
|
|
_, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
gid.New(fixture.tenantID, coredata.OAuth2RefreshTokenEntityType),
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
errUnsupported, ok := errors.AsType[*iam.ErrBatchAuthorizationUnsupportedResourceType](err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, coredata.OAuth2RefreshTokenEntityType, errUnsupported.EntityType)
|
|
})
|
|
|
|
t.Run("single deny rolls back entire batch", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, &fixture.frameworkID1)
|
|
|
|
_, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.frameworkID2,
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
_, ok := err.(*iam.ErrInsufficientPermissions)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 0, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("duplicate resources in batch", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
|
|
scope, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.frameworkID1,
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, scope)
|
|
assert.Equal(t, fixture.tenantID, scope.GetTenantID())
|
|
assert.Equal(t, 2, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("empty input", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
authorizer := iam.NewAuthorizer(nil, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry())
|
|
|
|
_, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: gid.New(gid.NilTenant, coredata.IdentityEntityType),
|
|
Action: newBatchTestAction(),
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
_, ok := err.(*iam.ErrEmptyResourceBatch)
|
|
require.True(t, ok)
|
|
})
|
|
|
|
t.Run("dry-run does not write audit logs", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
|
|
scope, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.frameworkID2,
|
|
},
|
|
DryRun: true,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, scope)
|
|
assert.Equal(t, fixture.tenantID, scope.GetTenantID())
|
|
assert.Equal(t, 0, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("missing principal identity returns wrapped principal attributes error", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
missingPrincipalID := gid.New(gid.NilTenant, coredata.IdentityEntityType)
|
|
|
|
_, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: missingPrincipalID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
assert.ErrorContains(t, err, "cannot build principal attributes")
|
|
assert.ErrorIs(t, err, coredata.ErrResourceNotFound)
|
|
})
|
|
|
|
t.Run("bulk insert failure aborts transaction", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizerWithIdentityScopedStatements(
|
|
client,
|
|
policy.Allow(action).WithSID("allow-audit-log-failure"),
|
|
)
|
|
nonExistentOrgID := gid.New(gid.NewTenantID(), coredata.OrganizationEntityType)
|
|
|
|
scope, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.frameworkID2,
|
|
},
|
|
ResourceAttributes: policy.Attributes{
|
|
"organization_id": nonExistentOrgID.String(),
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
assert.Nil(t, scope)
|
|
assert.ErrorContains(t, err, "cannot commit transaction")
|
|
assert.Equal(t, 0, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("assumption required", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
sessionID := gid.New(gid.NilTenant, coredata.SessionEntityType)
|
|
|
|
_, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Session: &sessionID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.frameworkID2,
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
_, ok := err.(*iam.ErrAssumptionRequired)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 0, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("assumption succeeds with active child session", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
rootSessionID := gid.New(gid.NilTenant, coredata.SessionEntityType)
|
|
|
|
insertBatchTestChildSession(
|
|
t,
|
|
context.Background(),
|
|
client,
|
|
fixture,
|
|
rootSessionID,
|
|
false,
|
|
)
|
|
|
|
scope, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Session: &rootSessionID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.frameworkID2,
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, scope)
|
|
assert.Equal(t, fixture.tenantID, scope.GetTenantID())
|
|
assert.Equal(t, 2, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("assumption fails when child session is expired", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
rootSessionID := gid.New(gid.NilTenant, coredata.SessionEntityType)
|
|
|
|
insertBatchTestChildSession(
|
|
t,
|
|
context.Background(),
|
|
client,
|
|
fixture,
|
|
rootSessionID,
|
|
true,
|
|
)
|
|
|
|
_, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: fixture.identityID,
|
|
Session: &rootSessionID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
fixture.frameworkID2,
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
_, ok := errors.AsType[*iam.ErrAssumptionRequired](err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 0, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("no membership ignores assumption check", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizer(client, action, nil)
|
|
rootSessionID := gid.New(gid.NilTenant, coredata.SessionEntityType)
|
|
identityWithoutMembershipID := insertBatchTestIdentity(
|
|
t,
|
|
context.Background(),
|
|
client,
|
|
fixture.tenantID,
|
|
)
|
|
|
|
_, err := authorizer.AuthorizeBatch(
|
|
context.Background(),
|
|
iam.AuthorizeBatchParams{
|
|
Principal: identityWithoutMembershipID,
|
|
Session: &rootSessionID,
|
|
Action: action,
|
|
Resources: []gid.GID{
|
|
fixture.frameworkID1,
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
|
|
_, hasAssumptionErr := errors.AsType[*iam.ErrAssumptionRequired](err)
|
|
assert.False(t, hasAssumptionErr)
|
|
|
|
insufficientPermissionsErr, ok := errors.AsType[*iam.ErrInsufficientPermissions](err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, identityWithoutMembershipID, insufficientPermissionsErr.IdentityID)
|
|
assert.Equal(t, 0, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
}
|
|
|
|
func TestAuthorizer_AuthorizeMulti(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("returns nil decisions when every item is allowed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizerWithStatements(
|
|
client,
|
|
policy.Allow(action).WithSID("allow-evaluate-multi-all"),
|
|
)
|
|
|
|
scope, decisions, err := authorizer.AuthorizeMulti(
|
|
context.Background(),
|
|
iam.AuthorizeMultiParams{
|
|
Principal: fixture.identityID,
|
|
Items: []iam.MultiAuthorizeItem{
|
|
{
|
|
Resource: fixture.frameworkID1,
|
|
Action: action,
|
|
},
|
|
{
|
|
Resource: fixture.frameworkID2,
|
|
Action: action,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, scope)
|
|
assert.Equal(t, fixture.tenantID, scope.GetTenantID())
|
|
require.Len(t, decisions, 2)
|
|
assert.NoError(t, decisions[0])
|
|
assert.NoError(t, decisions[1])
|
|
assert.Equal(t, 2, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("returns per-item decisions on partial denial without aborting", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizerWithStatements(
|
|
client,
|
|
policy.Allow(action).
|
|
WithSID("allow-only-first-framework").
|
|
When(policy.Equals("resource.id", fixture.frameworkID1.String())),
|
|
)
|
|
|
|
scope, decisions, err := authorizer.AuthorizeMulti(
|
|
context.Background(),
|
|
iam.AuthorizeMultiParams{
|
|
Principal: fixture.identityID,
|
|
Items: []iam.MultiAuthorizeItem{
|
|
{
|
|
Resource: fixture.frameworkID1,
|
|
Action: action,
|
|
},
|
|
{
|
|
Resource: fixture.frameworkID2,
|
|
Action: action,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, scope)
|
|
assert.Equal(t, fixture.tenantID, scope.GetTenantID())
|
|
require.Len(t, decisions, 2)
|
|
assert.NoError(t, decisions[0])
|
|
require.Error(t, decisions[1])
|
|
|
|
denied, ok := errors.AsType[*iam.ErrInsufficientPermissions](decisions[1])
|
|
require.True(t, ok)
|
|
assert.Equal(t, fixture.frameworkID2, denied.EntityID)
|
|
|
|
assert.Equal(t, 1, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("writes no audit logs when every item is denied", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizerWithStatements(
|
|
client,
|
|
policy.Allow(action).
|
|
WithSID("allow-no-frameworks").
|
|
When(policy.Equals("resource.id", "missing")),
|
|
)
|
|
|
|
scope, decisions, err := authorizer.AuthorizeMulti(
|
|
context.Background(),
|
|
iam.AuthorizeMultiParams{
|
|
Principal: fixture.identityID,
|
|
Items: []iam.MultiAuthorizeItem{
|
|
{
|
|
Resource: fixture.frameworkID1,
|
|
Action: action,
|
|
},
|
|
{
|
|
Resource: fixture.frameworkID2,
|
|
Action: action,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, scope)
|
|
require.Len(t, decisions, 2)
|
|
require.Error(t, decisions[0])
|
|
require.Error(t, decisions[1])
|
|
assert.Equal(t, 0, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("skips audit log entries for dry-run allowed items", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizerWithStatements(
|
|
client,
|
|
policy.Allow(action).WithSID("allow-evaluate-dry-run"),
|
|
)
|
|
|
|
_, decisions, err := authorizer.AuthorizeMulti(
|
|
context.Background(),
|
|
iam.AuthorizeMultiParams{
|
|
Principal: fixture.identityID,
|
|
Items: []iam.MultiAuthorizeItem{
|
|
{
|
|
Resource: fixture.frameworkID1,
|
|
Action: action,
|
|
DryRun: true,
|
|
},
|
|
{
|
|
Resource: fixture.frameworkID2,
|
|
Action: action,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, decisions, 2)
|
|
assert.NoError(t, decisions[0])
|
|
assert.NoError(t, decisions[1])
|
|
assert.Equal(t, 1, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("rejects mixed organization batch", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizerWithStatements(
|
|
client,
|
|
policy.Allow(action).WithSID("allow-evaluate-mixed-org"),
|
|
)
|
|
|
|
scope, decisions, err := authorizer.AuthorizeMulti(
|
|
context.Background(),
|
|
iam.AuthorizeMultiParams{
|
|
Principal: fixture.identityID,
|
|
Items: []iam.MultiAuthorizeItem{
|
|
{
|
|
Resource: fixture.frameworkID1,
|
|
Action: action,
|
|
},
|
|
{
|
|
Resource: fixture.frameworkID3,
|
|
Action: action,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
assert.Nil(t, scope)
|
|
assert.Nil(t, decisions)
|
|
|
|
_, ok := errors.AsType[*iam.ErrMixedOrganizationBatch](err)
|
|
require.True(t, ok)
|
|
})
|
|
|
|
t.Run("rejects empty items", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
authorizer := iam.NewAuthorizer(nil, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry())
|
|
|
|
scope, decisions, err := authorizer.AuthorizeMulti(
|
|
context.Background(),
|
|
iam.AuthorizeMultiParams{
|
|
Principal: gid.New(gid.NilTenant, coredata.IdentityEntityType),
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
assert.Nil(t, scope)
|
|
assert.Nil(t, decisions)
|
|
|
|
_, ok := errors.AsType[*iam.ErrEmptyResourceBatch](err)
|
|
require.True(t, ok)
|
|
})
|
|
|
|
t.Run("rejects unsupported principal type", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
authorizer := iam.NewAuthorizer(nil, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry())
|
|
|
|
scope, decisions, err := authorizer.AuthorizeMulti(
|
|
context.Background(),
|
|
iam.AuthorizeMultiParams{
|
|
Principal: gid.New(gid.NewTenantID(), coredata.OrganizationEntityType),
|
|
Items: []iam.MultiAuthorizeItem{
|
|
{
|
|
Resource: gid.New(gid.NewTenantID(), coredata.FrameworkEntityType),
|
|
Action: "core:test:get",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
require.Error(t, err)
|
|
assert.Nil(t, scope)
|
|
assert.Nil(t, decisions)
|
|
|
|
_, ok := errors.AsType[*iam.ErrUnsupportedPrincipalType](err)
|
|
require.True(t, ok)
|
|
})
|
|
|
|
t.Run("assumption error is recorded only on items that require the check", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizerWithStatements(
|
|
client,
|
|
policy.Allow(action).WithSID("allow-evaluate-mixed-skip-assumption"),
|
|
)
|
|
sessionID := gid.New(gid.NilTenant, coredata.SessionEntityType)
|
|
|
|
scope, decisions, err := authorizer.AuthorizeMulti(
|
|
context.Background(),
|
|
iam.AuthorizeMultiParams{
|
|
Principal: fixture.identityID,
|
|
Session: &sessionID,
|
|
Items: []iam.MultiAuthorizeItem{
|
|
{
|
|
Resource: fixture.frameworkID1,
|
|
Action: action,
|
|
SkipAssumptionCheck: true,
|
|
},
|
|
{
|
|
Resource: fixture.frameworkID2,
|
|
Action: action,
|
|
},
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, scope)
|
|
require.Len(t, decisions, 2)
|
|
assert.NoError(t, decisions[0])
|
|
require.Error(t, decisions[1])
|
|
|
|
_, ok := errors.AsType[*iam.ErrAssumptionRequired](decisions[1])
|
|
require.True(t, ok)
|
|
|
|
assert.Equal(t, 1, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
|
|
t.Run("per-item resource attributes are honoured by the policy", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
fixture := seedBatchAuthorizeFixture(t, context.Background(), client)
|
|
action := newBatchTestAction()
|
|
authorizer := newTestAuthorizerWithStatements(
|
|
client,
|
|
policy.Allow(action).
|
|
WithSID("allow-when-resource-flag-set").
|
|
When(policy.Equals("resource.flag", "on")),
|
|
)
|
|
|
|
scope, decisions, err := authorizer.AuthorizeMulti(
|
|
context.Background(),
|
|
iam.AuthorizeMultiParams{
|
|
Principal: fixture.identityID,
|
|
Items: []iam.MultiAuthorizeItem{
|
|
{
|
|
Resource: fixture.frameworkID1,
|
|
Action: action,
|
|
ResourceAttributes: policy.Attributes{"flag": "on"},
|
|
},
|
|
{
|
|
Resource: fixture.frameworkID2,
|
|
Action: action,
|
|
ResourceAttributes: policy.Attributes{"flag": "off"},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, scope)
|
|
require.Len(t, decisions, 2)
|
|
assert.NoError(t, decisions[0])
|
|
require.Error(t, decisions[1])
|
|
|
|
denied, ok := errors.AsType[*iam.ErrInsufficientPermissions](decisions[1])
|
|
require.True(t, ok)
|
|
assert.Equal(t, fixture.frameworkID2, denied.EntityID)
|
|
|
|
assert.Equal(t, 1, countAuditLogsForAction(t, context.Background(), client, action))
|
|
})
|
|
}
|
|
|
|
func newTestAuthorizer(client *pg.Client, action string, allowResourceID *gid.GID) *iam.Authorizer {
|
|
statement := policy.Allow(action).WithSID("allow-test-action")
|
|
if allowResourceID != nil {
|
|
statement = statement.When(policy.Equals("resource.id", allowResourceID.String()))
|
|
}
|
|
|
|
return newTestAuthorizerWithStatements(client, statement)
|
|
}
|
|
|
|
func newTestAuthorizerWithStatements(client *pg.Client, statements ...policy.Statement) *iam.Authorizer {
|
|
authorizer := iam.NewAuthorizer(client, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry())
|
|
authorizer.RegisterPolicySet(
|
|
iam.NewPolicySet().AddRolePolicy(
|
|
string(coredata.MembershipRoleOwner),
|
|
policy.NewPolicy("batch-authorize-test", "Batch Authorize Test", statements...),
|
|
),
|
|
)
|
|
|
|
return authorizer
|
|
}
|
|
|
|
func newTestAuthorizerWithIdentityScopedStatements(client *pg.Client, statements ...policy.Statement) *iam.Authorizer {
|
|
authorizer := iam.NewAuthorizer(client, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry())
|
|
authorizer.RegisterPolicySet(
|
|
iam.NewPolicySet().AddIdentityScopedPolicy(
|
|
policy.NewPolicy("batch-authorize-identity-test", "Batch Authorize Identity Test", statements...),
|
|
),
|
|
)
|
|
|
|
return authorizer
|
|
}
|
|
|
|
func seedBatchAuthorizeFixture(t *testing.T, ctx context.Context, client *pg.Client) batchAuthorizeFixture {
|
|
t.Helper()
|
|
|
|
tenantID := gid.NewTenantID()
|
|
scope := coredata.NewScope(tenantID)
|
|
identityID := gid.New(gid.NilTenant, coredata.IdentityEntityType)
|
|
organizationID := gid.New(tenantID, coredata.OrganizationEntityType)
|
|
organization2ID := gid.New(tenantID, coredata.OrganizationEntityType)
|
|
membershipID := gid.New(tenantID, coredata.MembershipEntityType)
|
|
profileID := gid.New(tenantID, coredata.MembershipProfileEntityType)
|
|
frameworkID1 := gid.New(tenantID, coredata.FrameworkEntityType)
|
|
frameworkID2 := gid.New(tenantID, coredata.FrameworkEntityType)
|
|
frameworkID3 := gid.New(tenantID, coredata.FrameworkEntityType)
|
|
now := time.Now().UTC()
|
|
|
|
emailAddress, err := mail.ParseAddr(fmt.Sprintf("%s@example.com", tenantID))
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
|
identity := coredata.Identity{
|
|
ID: identityID,
|
|
EmailAddress: emailAddress,
|
|
FullName: "Batch Test User",
|
|
EmailAddressVerified: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := identity.Insert(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot insert identity: %w", err)
|
|
}
|
|
|
|
organization := coredata.Organization{
|
|
ID: organizationID,
|
|
TenantID: tenantID,
|
|
Name: "Batch Test Org A",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := organization.Insert(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot insert first organization: %w", err)
|
|
}
|
|
|
|
organization2 := coredata.Organization{
|
|
ID: organization2ID,
|
|
TenantID: tenantID,
|
|
Name: "Batch Test Org B",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := organization2.Insert(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot insert second organization: %w", err)
|
|
}
|
|
|
|
membership := coredata.Membership{
|
|
ID: membershipID,
|
|
IdentityID: identityID,
|
|
OrganizationID: organizationID,
|
|
Role: coredata.MembershipRoleOwner,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := membership.Insert(ctx, tx, scope); err != nil {
|
|
return fmt.Errorf("cannot insert membership: %w", err)
|
|
}
|
|
|
|
profile := coredata.MembershipProfile{
|
|
ID: profileID,
|
|
IdentityID: identityID,
|
|
OrganizationID: organizationID,
|
|
Source: coredata.ProfileSourceManual,
|
|
State: coredata.ProfileStateActive,
|
|
FullName: "Batch Test User",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := profile.Insert(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot insert membership profile: %w", err)
|
|
}
|
|
|
|
framework1 := coredata.Framework{
|
|
ID: frameworkID1,
|
|
OrganizationID: organizationID,
|
|
ReferenceID: fmt.Sprintf("batch-test-framework-1-%s", tenantID),
|
|
Name: "Batch Test Framework 1",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := framework1.Insert(ctx, tx, scope); err != nil {
|
|
return fmt.Errorf("cannot insert first framework: %w", err)
|
|
}
|
|
|
|
framework2 := coredata.Framework{
|
|
ID: frameworkID2,
|
|
OrganizationID: organizationID,
|
|
ReferenceID: fmt.Sprintf("batch-test-framework-2-%s", tenantID),
|
|
Name: "Batch Test Framework 2",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := framework2.Insert(ctx, tx, scope); err != nil {
|
|
return fmt.Errorf("cannot insert second framework: %w", err)
|
|
}
|
|
|
|
framework3 := coredata.Framework{
|
|
ID: frameworkID3,
|
|
OrganizationID: organization2ID,
|
|
ReferenceID: fmt.Sprintf("batch-test-framework-3-%s", tenantID),
|
|
Name: "Batch Test Framework 3",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := framework3.Insert(ctx, tx, scope); err != nil {
|
|
return fmt.Errorf("cannot insert third framework: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}))
|
|
|
|
return batchAuthorizeFixture{
|
|
tenantID: tenantID,
|
|
identityID: identityID,
|
|
membershipID: membershipID,
|
|
organizationID: organizationID,
|
|
organization2ID: organization2ID,
|
|
frameworkID1: frameworkID1,
|
|
frameworkID2: frameworkID2,
|
|
frameworkID3: frameworkID3,
|
|
}
|
|
}
|
|
|
|
func insertBatchTestIdentity(
|
|
t *testing.T,
|
|
ctx context.Context,
|
|
client *pg.Client,
|
|
tenantID gid.TenantID,
|
|
) gid.GID {
|
|
t.Helper()
|
|
|
|
identityID := gid.New(gid.NilTenant, coredata.IdentityEntityType)
|
|
now := time.Now().UTC()
|
|
|
|
emailAddress, err := mail.ParseAddr(fmt.Sprintf("%s-no-membership@example.com", tenantID))
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
|
identity := coredata.Identity{
|
|
ID: identityID,
|
|
EmailAddress: emailAddress,
|
|
FullName: "Batch Test No Membership User",
|
|
EmailAddressVerified: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := identity.Insert(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot insert identity without membership: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}))
|
|
|
|
return identityID
|
|
}
|
|
|
|
func insertBatchTestChildSession(
|
|
t *testing.T,
|
|
ctx context.Context,
|
|
client *pg.Client,
|
|
fixture batchAuthorizeFixture,
|
|
rootSessionID gid.GID,
|
|
expired bool,
|
|
) gid.GID {
|
|
t.Helper()
|
|
|
|
childSessionID := gid.New(gid.NilTenant, coredata.SessionEntityType)
|
|
now := time.Now().UTC()
|
|
expiredAt := now.Add(30 * time.Minute)
|
|
|
|
var expireReason *coredata.ExpireReason
|
|
|
|
if expired {
|
|
reason := coredata.ExpireReasonRevoked
|
|
expireReason = &reason
|
|
expiredAt = now.Add(-1 * time.Minute)
|
|
}
|
|
|
|
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
|
rootSession := coredata.Session{
|
|
ID: rootSessionID,
|
|
IdentityID: fixture.identityID,
|
|
Data: coredata.SessionData{},
|
|
AuthMethod: coredata.AuthMethodPassword,
|
|
AuthenticatedAt: now,
|
|
UserAgent: "batch-test-root-agent",
|
|
IPAddress: net.ParseIP("127.0.0.1"),
|
|
ExpiredAt: now.Add(30 * time.Minute),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := rootSession.Insert(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot insert root session: %w", err)
|
|
}
|
|
|
|
session := coredata.Session{
|
|
ID: childSessionID,
|
|
IdentityID: fixture.identityID,
|
|
TenantID: &fixture.tenantID,
|
|
MembershipID: &fixture.membershipID,
|
|
ParentSessionID: &rootSessionID,
|
|
Data: coredata.SessionData{},
|
|
AuthMethod: coredata.AuthMethodPassword,
|
|
AuthenticatedAt: now,
|
|
UserAgent: "batch-test-agent",
|
|
IPAddress: net.ParseIP("127.0.0.1"),
|
|
ExpireReason: expireReason,
|
|
ExpiredAt: expiredAt,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := session.Insert(ctx, tx); err != nil {
|
|
return fmt.Errorf("cannot insert child session: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}))
|
|
|
|
return childSessionID
|
|
}
|
|
|
|
func countAuditLogsForAction(t *testing.T, ctx context.Context, client *pg.Client, action string) int {
|
|
t.Helper()
|
|
|
|
var count int
|
|
|
|
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
|
if err := tx.QueryRow(
|
|
ctx,
|
|
"SELECT COUNT(id) FROM audit_log_entries WHERE action = $1",
|
|
action,
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("cannot query audit log count: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}))
|
|
|
|
return count
|
|
}
|
|
|
|
func newBatchTestAction() string {
|
|
return fmt.Sprintf("test:framework-%d:get", time.Now().UnixNano())
|
|
}
|