diff --git a/pkg/coredata/access_entry.go b/pkg/coredata/access_entry.go index b87a06245..732401309 100644 --- a/pkg/coredata/access_entry.go +++ b/pkg/coredata/access_entry.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -69,19 +70,42 @@ func (e AccessEntry) CursorKey(orderBy AccessEntryOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (e *AccessEntry) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM access_entries WHERE id = $1 LIMIT 1;` +func (e *AccessEntry) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM access_entries WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, e.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query access entry authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (e *AccessEntry) LoadByID( diff --git a/pkg/coredata/access_entry_decision_history.go b/pkg/coredata/access_entry_decision_history.go index 73be2fbf0..97f88b796 100644 --- a/pkg/coredata/access_entry_decision_history.go +++ b/pkg/coredata/access_entry_decision_history.go @@ -16,7 +16,6 @@ package coredata import ( "context" - "errors" "fmt" "maps" "time" @@ -24,6 +23,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" ) type ( @@ -92,19 +92,39 @@ INSERT INTO access_entry_decision_history ( func (h *AccessEntryDecisionHistory) AuthorizationAttributes( ctx context.Context, conn pg.Querier, -) (map[string]string, error) { - q := `SELECT organization_id FROM access_entry_decision_history WHERE id = $1 LIMIT 1;` + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM access_entry_decision_history WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, h.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot load authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (hs *AccessEntryDecisionHistories) LoadByEntryID( diff --git a/pkg/coredata/access_review_campaign.go b/pkg/coredata/access_review_campaign.go index 5ddf72031..d22f150ea 100644 --- a/pkg/coredata/access_review_campaign.go +++ b/pkg/coredata/access_review_campaign.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,19 +54,42 @@ func (c AccessReviewCampaign) CursorKey(orderBy AccessReviewCampaignOrderField) panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (c *AccessReviewCampaign) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM access_review_campaigns WHERE id = $1 LIMIT 1;` +func (c *AccessReviewCampaign) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM access_review_campaigns WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query access review campaign authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (c *AccessReviewCampaign) LoadByID( diff --git a/pkg/coredata/access_source.go b/pkg/coredata/access_source.go index 7d12f588b..a0971c8ee 100644 --- a/pkg/coredata/access_source.go +++ b/pkg/coredata/access_source.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -52,19 +53,42 @@ func (as AccessSource) CursorKey(orderBy AccessSourceOrderField) page.CursorKey panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (as *AccessSource) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM access_sources WHERE id = $1 LIMIT 1;` +func (as *AccessSource) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM access_sources WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, as.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query access source authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (as *AccessSource) LoadByID( diff --git a/pkg/coredata/agent_run.go b/pkg/coredata/agent_run.go index 9f488aca5..bf52771d3 100644 --- a/pkg/coredata/agent_run.go +++ b/pkg/coredata/agent_run.go @@ -27,6 +27,7 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/agent" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -121,30 +122,42 @@ func (e AgentRun) CursorKey(orderBy AgentRunOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (e *AgentRun) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM agent_runs WHERE id = @id LIMIT 1;` +func (e *AgentRun) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM agent_runs WHERE id = ANY(@resource_ids::text[])` - args := pgx.StrictNamedArgs{"id": e.ID.String()} + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, + } rows, err := conn.Query(ctx, q, args) if err != nil { - return nil, fmt.Errorf("cannot query agent run authorization attributes: %w", err) + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) } - type row struct { - OrganizationID gid.GID `db:"organization_id"` - } + defer rows.Close() - r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[row]) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) } - return nil, fmt.Errorf("cannot load agent run authorization attributes: %w", err) + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } } - return map[string]string{"organization_id": r.OrganizationID.String()}, nil + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (e *AgentRun) LoadByID( diff --git a/pkg/coredata/applicability_statement.go b/pkg/coredata/applicability_statement.go index 939cf2d7d..96e518737 100644 --- a/pkg/coredata/applicability_statement.go +++ b/pkg/coredata/applicability_statement.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -57,19 +58,42 @@ func (s ApplicabilityStatement) CursorKey(orderBy ApplicabilityStatementOrderFie panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (s *ApplicabilityStatement) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM applicability_statements WHERE id = $1 LIMIT 1;` +func (s *ApplicabilityStatement) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM applicability_statements WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query applicability statement authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (sac *ApplicabilityStatement) LoadByID( diff --git a/pkg/coredata/asset.go b/pkg/coredata/asset.go index e6f93f243..732270dc0 100644 --- a/pkg/coredata/asset.go +++ b/pkg/coredata/asset.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -55,19 +56,42 @@ func (a *Asset) CursorKey(field AssetOrderField) page.CursorKey { } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (a *Asset) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM assets WHERE id = $1 LIMIT 1;` +func (a *Asset) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM assets WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, a.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query asset authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (a *Asset) LoadByID( diff --git a/pkg/coredata/audit.go b/pkg/coredata/audit.go index 12f89f5df..a2fc6ed55 100644 --- a/pkg/coredata/audit.go +++ b/pkg/coredata/audit.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -61,19 +62,42 @@ func (a *Audit) CursorKey(field AuditOrderField) page.CursorKey { } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (a *Audit) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM audits WHERE id = $1 LIMIT 1;` +func (a *Audit) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM audits WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, a.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query audit authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (a *Audit) LoadByID( diff --git a/pkg/coredata/audit_log_entry.go b/pkg/coredata/audit_log_entry.go index 4c69a24ba..480d89e66 100644 --- a/pkg/coredata/audit_log_entry.go +++ b/pkg/coredata/audit_log_entry.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,19 +54,42 @@ func (e AuditLogEntry) CursorKey(orderBy AuditLogEntryOrderField) page.CursorKey panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (e *AuditLogEntry) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM audit_log_entries WHERE id = $1 LIMIT 1;` +func (e *AuditLogEntry) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM audit_log_entries WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, e.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query audit log entry authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (e *AuditLogEntry) Insert( @@ -121,6 +145,61 @@ VALUES ( return nil } +// BulkInsert writes all entries to audit_log_entries in a single PostgreSQL +// COPY operation. Used by the authorizer's batch path to fold N per-resource +// INSERTs into a single round trip. +func (es AuditLogEntries) BulkInsert( + ctx context.Context, + conn pg.Querier, + scope Scoper, +) error { + if len(es) == 0 { + return nil + } + + rows := make([][]any, 0, len(es)) + for _, e := range es { + rows = append( + rows, + []any{ + e.ID, + scope.GetTenantID(), + e.OrganizationID, + e.ActorID, + e.ActorType, + e.Action, + e.ResourceType, + e.ResourceID, + e.Metadata, + e.CreatedAt, + }, + ) + } + + _, err := conn.CopyFrom( + ctx, + pgx.Identifier{"audit_log_entries"}, + []string{ + "id", + "tenant_id", + "organization_id", + "actor_id", + "actor_type", + "action", + "resource_type", + "resource_id", + "metadata", + "created_at", + }, + pgx.CopyFromRows(rows), + ) + if err != nil { + return fmt.Errorf("cannot bulk insert audit log entries: %w", err) + } + + return nil +} + func (e *AuditLogEntry) LoadByID( ctx context.Context, conn pg.Querier, diff --git a/pkg/coredata/compliance_external_url.go b/pkg/coredata/compliance_external_url.go index a5a140a26..f9005a9e8 100644 --- a/pkg/coredata/compliance_external_url.go +++ b/pkg/coredata/compliance_external_url.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,19 +54,42 @@ func (c ComplianceExternalURL) CursorKey(orderBy ComplianceExternalURLOrderField panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (c *ComplianceExternalURL) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM compliance_external_urls WHERE id = $1 LIMIT 1;` +func (c *ComplianceExternalURL) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM compliance_external_urls WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query compliance external URL authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (c *ComplianceExternalURL) LoadByID( diff --git a/pkg/coredata/compliance_framework.go b/pkg/coredata/compliance_framework.go index d121cf1af..c8c339df6 100644 --- a/pkg/coredata/compliance_framework.go +++ b/pkg/coredata/compliance_framework.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -56,19 +57,42 @@ func (c ComplianceFramework) CursorKey(orderBy ComplianceFrameworkOrderField) pa panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (c *ComplianceFramework) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM compliance_frameworks WHERE id = $1 LIMIT 1;` +func (c *ComplianceFramework) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM compliance_frameworks WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query compliance framework authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (c *ComplianceFramework) LoadByID( diff --git a/pkg/coredata/connector.go b/pkg/coredata/connector.go index 6aeb1a395..164522138 100644 --- a/pkg/coredata/connector.go +++ b/pkg/coredata/connector.go @@ -28,6 +28,7 @@ import ( "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -85,19 +86,42 @@ func (c *Connector) CursorKey(orderBy ConnectorOrderField) page.CursorKey { } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (c *Connector) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM connectors WHERE id = $1 LIMIT 1;` +func (c *Connector) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM connectors WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query connector authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (c *Connectors) LoadAllByOrganizationIDProtocolAndProvider( diff --git a/pkg/coredata/control.go b/pkg/coredata/control.go index 54bcae562..ccca097dc 100644 --- a/pkg/coredata/control.go +++ b/pkg/coredata/control.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -58,19 +59,42 @@ func (c Control) CursorKey(orderBy ControlOrderField) page.CursorKey { } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (c *Control) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM controls WHERE id = $1 LIMIT 1;` +func (c *Control) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM controls WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query control authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (c *Controls) CountByDocumentID( diff --git a/pkg/coredata/cookie_banner.go b/pkg/coredata/cookie_banner.go index 808a786c5..193ec8ff2 100644 --- a/pkg/coredata/cookie_banner.go +++ b/pkg/coredata/cookie_banner.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -57,19 +58,42 @@ func (b *CookieBanner) CursorKey(field CookieBannerOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (b *CookieBanner) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM cookie_banners WHERE id = $1 LIMIT 1;` +func (b *CookieBanner) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM cookie_banners WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, b.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query cookie banner authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (b *CookieBanner) LoadByID( diff --git a/pkg/coredata/cookie_banner_translation.go b/pkg/coredata/cookie_banner_translation.go index 593197adb..c43aaee5a 100644 --- a/pkg/coredata/cookie_banner_translation.go +++ b/pkg/coredata/cookie_banner_translation.go @@ -26,6 +26,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" ) type ( @@ -42,19 +43,42 @@ type ( CookieBannerTranslations []*CookieBannerTranslation ) -func (t *CookieBannerTranslation) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM cookie_banner_translations WHERE id = $1 LIMIT 1;` +func (t *CookieBannerTranslation) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM cookie_banner_translations WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, t.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query cookie banner translation authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (t *CookieBannerTranslation) LoadByID( diff --git a/pkg/coredata/cookie_banner_version.go b/pkg/coredata/cookie_banner_version.go index 152b978cd..86d6a7460 100644 --- a/pkg/coredata/cookie_banner_version.go +++ b/pkg/coredata/cookie_banner_version.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -80,19 +81,42 @@ func (v *CookieBannerVersion) CursorKey(field CookieBannerVersionOrderField) pag panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (v *CookieBannerVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM cookie_banner_versions WHERE id = $1 LIMIT 1;` +func (v *CookieBannerVersion) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM cookie_banner_versions WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query cookie banner version authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (v *CookieBannerVersion) GetSnapshot() (CookieBannerVersionSnapshot, error) { diff --git a/pkg/coredata/cookie_category.go b/pkg/coredata/cookie_category.go index 0c78b48c3..e63c4502f 100644 --- a/pkg/coredata/cookie_category.go +++ b/pkg/coredata/cookie_category.go @@ -26,6 +26,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -82,19 +83,42 @@ func (c *CookieCategory) CursorKey(field CookieCategoryOrderField) page.CursorKe panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (c *CookieCategory) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM cookie_categories WHERE id = $1 LIMIT 1;` +func (c *CookieCategory) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM cookie_categories WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query cookie category authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (c *CookieCategory) LoadByID( diff --git a/pkg/coredata/cookie_consent_record.go b/pkg/coredata/cookie_consent_record.go index d67e2ad5e..2f40b5742 100644 --- a/pkg/coredata/cookie_consent_record.go +++ b/pkg/coredata/cookie_consent_record.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -58,19 +59,42 @@ func (r *CookieConsentRecord) CursorKey(field CookieConsentRecordOrderField) pag panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (r *CookieConsentRecord) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM cookie_consent_records WHERE id = $1 LIMIT 1;` +func (r *CookieConsentRecord) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM cookie_consent_records WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, r.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query consent record authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (r *CookieConsentRecords) LoadByCookieBannerID( diff --git a/pkg/coredata/custom_domain.go b/pkg/coredata/custom_domain.go index a494bc159..8db662d26 100644 --- a/pkg/coredata/custom_domain.go +++ b/pkg/coredata/custom_domain.go @@ -27,6 +27,7 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -68,19 +69,42 @@ func NewCustomDomain(tenantID gid.TenantID, domain string) *CustomDomain { } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (cd *CustomDomain) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM custom_domains WHERE id = $1 LIMIT 1;` +func (cd *CustomDomain) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM custom_domains WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, cd.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query custom domain authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (cd *CustomDomain) CursorKey(field CustomDomainOrderField) page.CursorKey { diff --git a/pkg/coredata/data_protection_impact_assessment.go b/pkg/coredata/data_protection_impact_assessment.go index 9b6e61bf6..7c40ac92f 100644 --- a/pkg/coredata/data_protection_impact_assessment.go +++ b/pkg/coredata/data_protection_impact_assessment.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -163,19 +164,42 @@ func (dpia *DataProtectionImpactAssessment) CursorKey(field DataProtectionImpact } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (dpia *DataProtectionImpactAssessment) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM processing_activity_data_protection_impact_assessments WHERE id = $1 LIMIT 1;` +func (dpia *DataProtectionImpactAssessment) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM processing_activity_data_protection_impact_assessments WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, dpia.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query data protection impact assessment authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (dpias *DataProtectionImpactAssessments) CountByOrganizationID( diff --git a/pkg/coredata/datum.go b/pkg/coredata/datum.go index 1db12da29..8bdb80dbb 100644 --- a/pkg/coredata/datum.go +++ b/pkg/coredata/datum.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -55,19 +56,42 @@ func (d *Datum) CursorKey(field DatumOrderField) page.CursorKey { } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (d *Datum) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM data WHERE id = $1 LIMIT 1;` +func (d *Datum) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM data WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query datum authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (d *Datum) LoadByID( diff --git a/pkg/coredata/document.go b/pkg/coredata/document.go index 4e2aea6a3..e3194cc6f 100644 --- a/pkg/coredata/document.go +++ b/pkg/coredata/document.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" ) @@ -65,26 +66,42 @@ func (p Document) CursorKey(orderBy DocumentOrderField) page.CursorKey { } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (d *Document) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := ` -SELECT organization_id -FROM documents -WHERE id = $1 -LIMIT 1; -` +func (d *Document) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM documents WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query document authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{ - "organization_id": organizationID.String(), - }, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (p *Document) LoadByID( diff --git a/pkg/coredata/document_version.go b/pkg/coredata/document_version.go index 1f895a952..ca085d188 100644 --- a/pkg/coredata/document_version.go +++ b/pkg/coredata/document_version.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,27 +54,42 @@ type ( ) // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (dv *DocumentVersion) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := ` -SELECT organization_id -FROM document_versions -WHERE id = $1 -LIMIT 1; -` +func (dv *DocumentVersion) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM document_versions WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - - if err := conn.QueryRow(ctx, q, dv.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query document version authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{ - "organization_id": organizationID.String(), - }, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (dv *DocumentVersions) LoadByDocumentID( diff --git a/pkg/coredata/document_version_approval_decision.go b/pkg/coredata/document_version_approval_decision.go index 21b1ab3c5..3797fa945 100644 --- a/pkg/coredata/document_version_approval_decision.go +++ b/pkg/coredata/document_version_approval_decision.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -54,19 +55,42 @@ func (d DocumentVersionApprovalDecision) CursorKey(orderBy DocumentVersionApprov panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (d *DocumentVersionApprovalDecision) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM document_version_approval_decisions WHERE id = $1 LIMIT 1;` +func (d *DocumentVersionApprovalDecision) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM document_version_approval_decisions WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, d.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query document version approval decision authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (d *DocumentVersionApprovalDecision) LoadByID( diff --git a/pkg/coredata/document_version_approval_quorum.go b/pkg/coredata/document_version_approval_quorum.go index 177a9a496..c579568e4 100644 --- a/pkg/coredata/document_version_approval_quorum.go +++ b/pkg/coredata/document_version_approval_quorum.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -50,19 +51,42 @@ func (q DocumentVersionApprovalQuorum) CursorKey(orderBy DocumentVersionApproval panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (q *DocumentVersionApprovalQuorum) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - query := `SELECT organization_id FROM document_version_approval_quorums WHERE id = $1 LIMIT 1;` +func (q *DocumentVersionApprovalQuorum) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + query := `SELECT id, organization_id FROM document_version_approval_quorums WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, query, q.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query approval quorum authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, query, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (q *DocumentVersionApprovalQuorum) LoadByID( diff --git a/pkg/coredata/document_version_signature.go b/pkg/coredata/document_version_signature.go index dd3735a29..ea117717a 100644 --- a/pkg/coredata/document_version_signature.go +++ b/pkg/coredata/document_version_signature.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" ) @@ -64,19 +65,42 @@ func (pvs DocumentVersionSignature) CursorKey(orderBy DocumentVersionSignatureOr } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (dvs *DocumentVersionSignature) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM document_version_signatures WHERE id = $1 LIMIT 1;` +func (dvs *DocumentVersionSignature) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM document_version_signatures WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, dvs.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query document version signature authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (pvs *DocumentVersionSignature) LoadByDocumentVersionIDAndSignatory( diff --git a/pkg/coredata/email.go b/pkg/coredata/email.go index f7fbe9af9..333e56c05 100644 --- a/pkg/coredata/email.go +++ b/pkg/coredata/email.go @@ -23,6 +23,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" ) @@ -65,8 +66,46 @@ var ( // AuthorizationAttributes returns the authorization attributes for policy evaluation. // Email is identity-scoped (not org-scoped), so it returns an empty map. -func (e *Email) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - return map[string]string{}, nil +func (e *Email) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := ` +SELECT + id +FROM + emails +WHERE + id = ANY(@resource_ids::text[]) +` + + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, + } + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query email authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + for rows.Next() { + var id gid.GID + err = rows.Scan(&id) + if err != nil { + return nil, fmt.Errorf("cannot scan email authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{} + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate email authorization attributes: %w", err) + } + + return attrsByID, nil } func NewEmail( diff --git a/pkg/coredata/evidence.go b/pkg/coredata/evidence.go index 52b99fffd..26b30e340 100644 --- a/pkg/coredata/evidence.go +++ b/pkg/coredata/evidence.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -59,19 +60,42 @@ func (e Evidence) CursorKey(orderBy EvidenceOrderField) page.CursorKey { } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (e *Evidence) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM evidences WHERE id = $1 LIMIT 1;` +func (e *Evidence) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM evidences WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, e.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query evidence authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (e Evidence) Upsert( diff --git a/pkg/coredata/export_job.go b/pkg/coredata/export_job.go index 33c3e7108..8f288c7e5 100644 --- a/pkg/coredata/export_job.go +++ b/pkg/coredata/export_job.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" ) @@ -63,19 +64,42 @@ var ( ) // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (ej *ExportJob) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM export_jobs WHERE id = $1 LIMIT 1;` +func (ej *ExportJob) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM export_jobs WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, ej.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query export job authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (ej *ExportJob) Insert( diff --git a/pkg/coredata/file.go b/pkg/coredata/file.go index fdea489b9..2a3e13d60 100644 --- a/pkg/coredata/file.go +++ b/pkg/coredata/file.go @@ -26,6 +26,7 @@ import ( "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" ) type ( @@ -65,19 +66,42 @@ func (f *File) GetMimeType() string { var _ filemanager.File = (*File)(nil) // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (f *File) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM files WHERE id = $1 LIMIT 1;` +func (f *File) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM files WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, f.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query file authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (f *File) LoadByID( diff --git a/pkg/coredata/finding.go b/pkg/coredata/finding.go index 8d33608a8..86c626a0a 100644 --- a/pkg/coredata/finding.go +++ b/pkg/coredata/finding.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -72,19 +73,42 @@ func (f *Finding) CursorKey(field FindingOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (f *Finding) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM findings WHERE id = $1 LIMIT 1;` +func (f *Finding) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM findings WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, f.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query finding authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (f *Finding) LoadByID( diff --git a/pkg/coredata/framework.go b/pkg/coredata/framework.go index 195c45d80..7751a5056 100644 --- a/pkg/coredata/framework.go +++ b/pkg/coredata/framework.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,20 +54,49 @@ func (f *Framework) CursorKey(orderBy FrameworkOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -// AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (f *Framework) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM frameworks WHERE id = $1 LIMIT 1;` +func (f *Framework) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + frameworkIDs []gid.GID, +) (policy.AttributesByID, error) { + q := ` +SELECT + id, + organization_id +FROM + frameworks +WHERE + id = ANY(@framework_ids::text[]) +` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, f.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query framework authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "framework_ids": frameworkIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query framework authorization attributes batch: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var ( + frameworkID gid.GID + organizationID gid.GID + ) + if err := rows.Scan(&frameworkID, &organizationID); err != nil { + return nil, fmt.Errorf("cannot scan framework authorization attributes batch: %w", err) + } + attrsByID[frameworkID] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate framework authorization attributes batch: %w", err) + } + + return attrsByID, nil } func (f *Frameworks) CountByOrganizationID( @@ -100,6 +130,22 @@ WHERE return count, nil } +func uniqueGIDs(values []gid.GID) []gid.GID { + set := make(map[gid.GID]struct{}, len(values)) + unique := make([]gid.GID, 0, len(values)) + + for _, value := range values { + if _, ok := set[value]; ok { + continue + } + + set[value] = struct{}{} + unique = append(unique, value) + } + + return unique +} + func (f *Frameworks) LoadByOrganizationID( ctx context.Context, conn pg.Querier, diff --git a/pkg/coredata/identity.go b/pkg/coredata/identity.go index 8e91b415d..c374c2978 100644 --- a/pkg/coredata/identity.go +++ b/pkg/coredata/identity.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" ) @@ -143,7 +144,11 @@ LIMIT 1; // AuthorizationAttributes loads the minimal authorization attributes for policy condition evaluation. // It is intentionally lightweight and does not populate the Identity struct. -func (i *Identity) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { +func (i *Identity) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { q := ` SELECT id, @@ -151,25 +156,41 @@ SELECT FROM identities WHERE - id = $1 + id = ANY(@resource_ids::text[]) ` - var ( - id gid.GID - emailAddress string - ) - if err := conn.QueryRow(ctx, q, i.ID).Scan(&id, &emailAddress); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query identity iam attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{ - "identity_id": id.String(), - "email": emailAddress, - }, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query identity authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + for rows.Next() { + var ( + id gid.GID + emailAddress string + ) + + if err := rows.Scan(&id, &emailAddress); err != nil { + return nil, fmt.Errorf("cannot scan identity authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "identity_id": id.String(), + "email": emailAddress, + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate identity authorization attributes: %w", err) + } + + return attrsByID, nil } func (i *Identity) Insert( diff --git a/pkg/coredata/invitation.go b/pkg/coredata/invitation.go index 06cc4508b..a1652567e 100644 --- a/pkg/coredata/invitation.go +++ b/pkg/coredata/invitation.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -142,34 +143,56 @@ WHERE // AuthorizationAttributes loads the minimal authorization attributes for policy condition evaluation. // It is intentionally lightweight and does not populate the Invitation struct. -func (i *Invitation) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { +func (i *Invitation) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { q := ` SELECT - email, organization_id + id, + email, + organization_id FROM iam_invitations WHERE - id = $1 -LIMIT 1; + id = ANY(@resource_ids::text[]) ` - var ( - email string - organizationID gid.GID - ) - - if err := conn.QueryRow(ctx, q, i.ID).Scan(&email, &organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query invitation iam attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{ - "email": email, - "organization_id": organizationID.String(), - }, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query invitation authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + for rows.Next() { + var ( + id gid.GID + email string + organizationID gid.GID + ) + + err = rows.Scan(&id, &email, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan invitation authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "email": email, + "organization_id": organizationID.String(), + } + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate invitation authorization attributes: %w", err) + } + + return attrsByID, nil } func (i *Invitation) Update(ctx context.Context, conn pg.Tx, scope Scoper) error { diff --git a/pkg/coredata/mailing_list.go b/pkg/coredata/mailing_list.go index 544f0eb76..bafaba41d 100644 --- a/pkg/coredata/mailing_list.go +++ b/pkg/coredata/mailing_list.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" ) @@ -35,19 +36,42 @@ type MailingList struct { UpdatedAt time.Time `db:"updated_at"` } -func (ml *MailingList) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM mailing_lists WHERE id = $1 LIMIT 1;` +func (ml *MailingList) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM mailing_lists WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, ml.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query mailing list authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (ml *MailingList) LoadByID( diff --git a/pkg/coredata/mailing_list_subscriber.go b/pkg/coredata/mailing_list_subscriber.go index ab6e79b5b..294b60714 100644 --- a/pkg/coredata/mailing_list_subscriber.go +++ b/pkg/coredata/mailing_list_subscriber.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" ) @@ -44,19 +45,42 @@ type ( MailingListSubscribers []*MailingListSubscriber ) -func (cns *MailingListSubscriber) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM mailing_list_subscribers WHERE id = $1 LIMIT 1;` +func (cns *MailingListSubscriber) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM mailing_list_subscribers WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, cns.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query mailing list subscriber authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (cns *MailingListSubscriber) CursorKey(orderBy MailingListSubscriberOrderField) page.CursorKey { diff --git a/pkg/coredata/mailing_list_update.go b/pkg/coredata/mailing_list_update.go index bc0510221..98357e043 100644 --- a/pkg/coredata/mailing_list_update.go +++ b/pkg/coredata/mailing_list_update.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,19 +54,42 @@ func (mlu *MailingListUpdate) CursorKey(orderBy MailingListUpdateOrderField) pag panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (mlu *MailingListUpdate) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM mailing_list_updates WHERE id = $1 LIMIT 1;` +func (mlu *MailingListUpdate) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM mailing_list_updates WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, mlu.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query mailing list update authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (mlu *MailingListUpdate) Insert(ctx context.Context, conn pg.Tx, scope Scoper) error { diff --git a/pkg/coredata/measure.go b/pkg/coredata/measure.go index d7be86f08..4eb898b69 100644 --- a/pkg/coredata/measure.go +++ b/pkg/coredata/measure.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -56,19 +57,42 @@ func (m Measure) CursorKey(orderBy MeasureOrderField) page.CursorKey { } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (m *Measure) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM measures WHERE id = $1 LIMIT 1;` +func (m *Measure) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM measures WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, m.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query measure authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (m *Measures) CountByRiskID( diff --git a/pkg/coredata/membership.go b/pkg/coredata/membership.go index d1d445bde..d31efb75a 100644 --- a/pkg/coredata/membership.go +++ b/pkg/coredata/membership.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -199,42 +200,64 @@ WHERE return nil } -func (m *Membership) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { +func (m *Membership) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { q := ` SELECT + id, identity_id, organization_id, role FROM iam_memberships WHERE - id = $1 -LIMIT 1; + id = ANY(@resource_ids::text[]) ` - var ( - identityID gid.GID - organizationID gid.GID - role MembershipRole - ) - - if err := conn.QueryRow(ctx, q, m.ID).Scan( - &identityID, - &organizationID, - &role, - ); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query membership iam attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{ - "identity_id": identityID.String(), - "organization_id": organizationID.String(), - "role": role.String(), - }, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query membership authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + for rows.Next() { + var ( + id gid.GID + identityID gid.GID + organizationID gid.GID + role MembershipRole + ) + + err = rows.Scan( + &id, + &identityID, + &organizationID, + &role, + ) + if err != nil { + return nil, fmt.Errorf("cannot scan membership authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "identity_id": identityID.String(), + "organization_id": organizationID.String(), + "role": role.String(), + } + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate membership authorization attributes: %w", err) + } + + return attrsByID, nil } func (m *Membership) LoadByIdentityAndOrg( diff --git a/pkg/coredata/membership_profile.go b/pkg/coredata/membership_profile.go index 795fff3e2..a265af95a 100644 --- a/pkg/coredata/membership_profile.go +++ b/pkg/coredata/membership_profile.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" ) @@ -87,26 +88,56 @@ func (p MembershipProfile) CursorKey(orderBy MembershipProfileOrderField) page.C panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (p *MembershipProfile) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id, identity_id FROM iam_membership_profiles WHERE id = $1 LIMIT 1;` +func (p *MembershipProfile) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := ` +SELECT + id, + organization_id, + identity_id +FROM + iam_membership_profiles +WHERE + id = ANY(@resource_ids::text[]) +` - var ( - organizationID gid.GID - identityID gid.GID - ) - - if err := conn.QueryRow(ctx, q, p.ID).Scan(&organizationID, &identityID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query profile authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{ - "organization_id": organizationID.String(), - "identity_id": identityID.String(), - }, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query profile authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + for rows.Next() { + var ( + id gid.GID + organizationID gid.GID + identityID gid.GID + ) + + err = rows.Scan(&id, &organizationID, &identityID) + if err != nil { + return nil, fmt.Errorf("cannot scan profile authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + "identity_id": identityID.String(), + } + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate profile authorization attributes: %w", err) + } + + return attrsByID, nil } func (p *MembershipProfile) LoadByID( diff --git a/pkg/coredata/oauth2_client.go b/pkg/coredata/oauth2_client.go index 232eb487e..42cbbb37c 100644 --- a/pkg/coredata/oauth2_client.go +++ b/pkg/coredata/oauth2_client.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/uri" ) @@ -72,32 +73,55 @@ func (c *OAuth2Client) CursorKey(orderBy OAuth2ClientOrderField) page.CursorKey panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (c *OAuth2Client) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { +func (c *OAuth2Client) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { q := ` SELECT - organization_id + id, + organization_id FROM - iam_oauth2_clients + iam_oauth2_clients WHERE - id = $1 -LIMIT 1; + id = ANY(@resource_ids::text[]) ` - var organizationID *gid.GID - if err := conn.QueryRow(ctx, q, c.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, + } + rows, err := conn.Query(ctx, q, args) + if err != nil { return nil, fmt.Errorf("cannot query oauth2 client authorization attributes: %w", err) } + defer rows.Close() - attrs := make(map[string]string) - if organizationID != nil { - attrs["organization_id"] = organizationID.String() + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + for rows.Next() { + var ( + id gid.GID + organizationID *gid.GID + ) + + err = rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan oauth2 client authorization attributes: %w", err) + } + + attrs := make(map[string]string) + if organizationID != nil { + attrs["organization_id"] = organizationID.String() + } + attrsByID[id] = attrs } - return attrs, nil + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate oauth2 client authorization attributes: %w", err) + } + + return attrsByID, nil } func (c *OAuth2Client) LoadByID( diff --git a/pkg/coredata/oauth2_consent.go b/pkg/coredata/oauth2_consent.go index f4b7e9061..5eef9d87e 100644 --- a/pkg/coredata/oauth2_consent.go +++ b/pkg/coredata/oauth2_consent.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/uri" ) @@ -58,31 +59,56 @@ func (c *OAuth2Consent) CursorKey(orderBy OAuth2ConsentOrderField) page.CursorKe panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (c *OAuth2Consent) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { +func (c *OAuth2Consent) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { q := ` SELECT - identity_id, - session_id + id, + identity_id, + session_id FROM - iam_oauth2_consents + iam_oauth2_consents WHERE - id = $1 -LIMIT 1; + id = ANY(@resource_ids::text[]) ` - var identityID, sessionID gid.GID - if err := conn.QueryRow(ctx, q, c.ID).Scan(&identityID, &sessionID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query oauth2_consent authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{ - "identity_id": identityID.String(), - "session_id": sessionID.String(), - }, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query oauth2 consent authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + for rows.Next() { + var ( + id gid.GID + identityID gid.GID + sessionID gid.GID + ) + + err = rows.Scan(&id, &identityID, &sessionID) + if err != nil { + return nil, fmt.Errorf("cannot scan oauth2 consent authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "identity_id": identityID.String(), + "session_id": sessionID.String(), + } + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate oauth2 consent authorization attributes: %w", err) + } + + return attrsByID, nil } func (c *OAuth2Consent) LoadByID( diff --git a/pkg/coredata/obligation.go b/pkg/coredata/obligation.go index b0657d675..b5522dc63 100644 --- a/pkg/coredata/obligation.go +++ b/pkg/coredata/obligation.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -63,19 +64,42 @@ func (o *Obligation) CursorKey(field ObligationOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (o *Obligation) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM obligations WHERE id = $1 LIMIT 1;` +func (o *Obligation) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM obligations WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, o.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query obligation authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (o *Obligation) LoadByID( diff --git a/pkg/coredata/organization.go b/pkg/coredata/organization.go index 4daf09078..144764e4d 100644 --- a/pkg/coredata/organization.go +++ b/pkg/coredata/organization.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -46,19 +47,41 @@ type ( Organizations []*Organization ) -func (o *Organization) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT id FROM organizations WHERE id = $1 LIMIT 1;` +func (o *Organization) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id FROM organizations WHERE id = ANY(@resource_ids::text[])` - var id gid.GID - if err := conn.QueryRow(ctx, q, o.ID).Scan(&id); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query organization authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": o.ID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id gid.GID + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": id.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (o Organization) CursorKey(orderBy OrganizationOrderField) page.CursorKey { diff --git a/pkg/coredata/personal_api_key.go b/pkg/coredata/personal_api_key.go index c37e99437..e3100b38e 100644 --- a/pkg/coredata/personal_api_key.go +++ b/pkg/coredata/personal_api_key.go @@ -23,6 +23,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -93,19 +94,51 @@ LIMIT 1; return nil } -func (a *PersonalAPIKey) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := "SELECT identity_id FROM iam_personal_api_keys WHERE id = $1 LIMIT 1;" +func (a *PersonalAPIKey) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := ` +SELECT + id, + identity_id +FROM + iam_personal_api_keys +WHERE + id = ANY(@resource_ids::text[]) +` - var identityID gid.GID - if err := conn.QueryRow(ctx, q, a.ID).Scan(&identityID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query personal api key iam attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"identity_id": identityID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query personal api key authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + for rows.Next() { + var ( + id gid.GID + identityID gid.GID + ) + + err = rows.Scan(&id, &identityID) + if err != nil { + return nil, fmt.Errorf("cannot scan personal api key authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{"identity_id": identityID.String()} + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate personal api key authorization attributes: %w", err) + } + + return attrsByID, nil } func (a *PersonalAPIKeys) LoadByIdentityID( diff --git a/pkg/coredata/processing_activities.go b/pkg/coredata/processing_activities.go index 2f3c74604..8d7de3775 100644 --- a/pkg/coredata/processing_activities.go +++ b/pkg/coredata/processing_activities.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -176,19 +177,42 @@ func (p *ProcessingActivity) CursorKey(field ProcessingActivityOrderField) page. panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (p *ProcessingActivity) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM processing_activities WHERE id = $1 LIMIT 1;` +func (p *ProcessingActivity) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM processing_activities WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, p.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query processing activity authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (p *ProcessingActivity) LoadByID( diff --git a/pkg/coredata/report.go b/pkg/coredata/report.go index e0c213612..87db4e0c7 100644 --- a/pkg/coredata/report.go +++ b/pkg/coredata/report.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -42,19 +43,42 @@ type ( Reports []*Report ) -func (r *Report) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM reports WHERE id = $1 LIMIT 1;` +func (r *Report) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM reports WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, r.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query report authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (r *Report) LoadByID( diff --git a/pkg/coredata/rights_requests.go b/pkg/coredata/rights_requests.go index 13d65399f..c06aeb4a6 100644 --- a/pkg/coredata/rights_requests.go +++ b/pkg/coredata/rights_requests.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -61,19 +62,42 @@ func (rr *RightsRequest) CursorKey(field RightsRequestOrderField) page.CursorKey } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (rr *RightsRequest) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM rights_requests WHERE id = $1 LIMIT 1;` +func (rr *RightsRequest) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM rights_requests WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, rr.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query rights request authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (rr *RightsRequest) LoadByID( diff --git a/pkg/coredata/risk.go b/pkg/coredata/risk.go index 11cc4b371..18a062365 100644 --- a/pkg/coredata/risk.go +++ b/pkg/coredata/risk.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -182,19 +183,42 @@ func (r *Risk) CursorKey(orderBy RiskOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (r *Risk) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM risks WHERE id = $1 LIMIT 1;` +func (r *Risk) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM risks WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, r.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query risk authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (r *Risks) CountByMeasureID( diff --git a/pkg/coredata/risk_assessment.go b/pkg/coredata/risk_assessment.go index 15c0aa52e..cf2700608 100644 --- a/pkg/coredata/risk_assessment.go +++ b/pkg/coredata/risk_assessment.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -51,19 +52,42 @@ func (ra *RiskAssessment) CursorKey(orderBy RiskAssessmentOrderField) page.Curso panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (ra *RiskAssessment) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM risk_assessments WHERE id = $1 LIMIT 1;` +func (ra *RiskAssessment) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM risk_assessments WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, ra.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query risk assessment authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (ra *RiskAssessments) CountByOrganizationID( diff --git a/pkg/coredata/risk_assessment_node.go b/pkg/coredata/risk_assessment_node.go index 84b4b1f2a..085131ce6 100644 --- a/pkg/coredata/risk_assessment_node.go +++ b/pkg/coredata/risk_assessment_node.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,19 +54,42 @@ func (n *RiskAssessmentNode) CursorKey(orderBy RiskAssessmentNodeOrderField) pag panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (n *RiskAssessmentNode) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM risk_assessment_nodes WHERE id = $1 LIMIT 1;` +func (n *RiskAssessmentNode) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM risk_assessment_nodes WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, n.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query risk assessment node authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (ns *RiskAssessmentNodes) LoadByRiskAssessmentScopeID( diff --git a/pkg/coredata/risk_assessment_process.go b/pkg/coredata/risk_assessment_process.go index 387d27d85..56bddbe91 100644 --- a/pkg/coredata/risk_assessment_process.go +++ b/pkg/coredata/risk_assessment_process.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -54,19 +55,42 @@ func (p *RiskAssessmentProcess) CursorKey(orderBy RiskAssessmentProcessOrderFiel panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (p *RiskAssessmentProcess) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM risk_assessment_processes WHERE id = $1 LIMIT 1;` +func (p *RiskAssessmentProcess) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM risk_assessment_processes WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, p.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query risk assessment process authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (ps *RiskAssessmentProcesses) LoadByRiskAssessmentScopeID( diff --git a/pkg/coredata/risk_assessment_scenario.go b/pkg/coredata/risk_assessment_scenario.go index 483e9334a..748f29035 100644 --- a/pkg/coredata/risk_assessment_scenario.go +++ b/pkg/coredata/risk_assessment_scenario.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -52,19 +53,42 @@ func (s *RiskAssessmentScenario) CursorKey(orderBy RiskAssessmentScenarioOrderFi panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (s *RiskAssessmentScenario) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM risk_assessment_scenarios WHERE id = $1 LIMIT 1;` +func (s *RiskAssessmentScenario) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM risk_assessment_scenarios WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query risk scenario authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (ss *RiskAssessmentScenarios) LoadByOrganizationID( diff --git a/pkg/coredata/risk_assessment_scope.go b/pkg/coredata/risk_assessment_scope.go index 9eac303db..36a264a44 100644 --- a/pkg/coredata/risk_assessment_scope.go +++ b/pkg/coredata/risk_assessment_scope.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -51,19 +52,42 @@ func (s *RiskAssessmentScope) CursorKey(orderBy RiskAssessmentScopeOrderField) p panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (s *RiskAssessmentScope) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM risk_assessment_scopes WHERE id = $1 LIMIT 1;` +func (s *RiskAssessmentScope) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM risk_assessment_scopes WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query risk assessment scope authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (ss *RiskAssessmentScopes) LoadByRiskAssessmentID( diff --git a/pkg/coredata/risk_assessment_threat.go b/pkg/coredata/risk_assessment_threat.go index a7c569e54..49fe9b9e1 100644 --- a/pkg/coredata/risk_assessment_threat.go +++ b/pkg/coredata/risk_assessment_threat.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -54,19 +55,42 @@ func (t *RiskAssessmentThreat) CursorKey(orderBy RiskAssessmentThreatOrderField) panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (t *RiskAssessmentThreat) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM risk_assessment_threats WHERE id = $1 LIMIT 1;` +func (t *RiskAssessmentThreat) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM risk_assessment_threats WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, t.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query risk assessment threat authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (ts *RiskAssessmentThreats) LoadByRiskAssessmentScopeID( diff --git a/pkg/coredata/saml_configuration.go b/pkg/coredata/saml_configuration.go index 8d72f4f0f..dca51aea3 100644 --- a/pkg/coredata/saml_configuration.go +++ b/pkg/coredata/saml_configuration.go @@ -27,6 +27,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -63,19 +64,42 @@ func (s *SAMLConfiguration) CursorKey(orderBy SAMLConfigurationOrderField) page. panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (s *SAMLConfiguration) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM iam_saml_configurations WHERE id = $1 LIMIT 1;` +func (s *SAMLConfiguration) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM iam_saml_configurations WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query saml configuration authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (s *SAMLConfiguration) GetIdPCertificate() (*x509.Certificate, error) { diff --git a/pkg/coredata/scim_bridge.go b/pkg/coredata/scim_bridge.go index 10c7097f6..29f0ecf6b 100644 --- a/pkg/coredata/scim_bridge.go +++ b/pkg/coredata/scim_bridge.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -60,19 +61,42 @@ func (s *SCIMBridge) CursorKey(orderBy SCIMBridgeOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (s *SCIMBridge) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM iam_scim_bridges WHERE id = $1 LIMIT 1;` +func (s *SCIMBridge) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM iam_scim_bridges WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query scim bridge authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (s *SCIMBridge) LoadByID( diff --git a/pkg/coredata/scim_configuration.go b/pkg/coredata/scim_configuration.go index 298e09f72..b4334f026 100644 --- a/pkg/coredata/scim_configuration.go +++ b/pkg/coredata/scim_configuration.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -50,19 +51,42 @@ func (s *SCIMConfiguration) CursorKey(orderBy SCIMConfigurationOrderField) page. panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (s *SCIMConfiguration) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM iam_scim_configurations WHERE id = $1 LIMIT 1;` +func (s *SCIMConfiguration) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM iam_scim_configurations WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query scim configuration authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (s *SCIMConfiguration) LoadByID( diff --git a/pkg/coredata/scim_event.go b/pkg/coredata/scim_event.go index f80804af9..fce195d8e 100644 --- a/pkg/coredata/scim_event.go +++ b/pkg/coredata/scim_event.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -56,19 +57,42 @@ func (s *SCIMEvent) CursorKey(orderBy SCIMEventOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (s *SCIMEvent) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM iam_scim_events WHERE id = $1 LIMIT 1;` +func (s *SCIMEvent) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM iam_scim_events WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query scim event authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (s *SCIMEvent) LoadByID( diff --git a/pkg/coredata/session.go b/pkg/coredata/session.go index e12e8df17..e5cca61c2 100644 --- a/pkg/coredata/session.go +++ b/pkg/coredata/session.go @@ -26,6 +26,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -194,27 +195,51 @@ LIMIT 1; // AuthorizationAttributes loads the minimal authorization attributes for policy condition evaluation. // It is intentionally lightweight and does not populate the Session struct. -func (s *Session) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { +func (s *Session) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { q := ` SELECT + id, identity_id FROM iam_sessions WHERE - id = $1 -LIMIT 1; + id = ANY(@resource_ids::text[]) ` - var identityID gid.GID - if err := conn.QueryRow(ctx, q, s.ID).Scan(&identityID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query session iam attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"identity_id": identityID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query session authorization attributes: %w", err) + } + defer rows.Close() + + attrsByID := make(policy.AttributesByID, len(resourceIDs)) + for rows.Next() { + var ( + id gid.GID + identityID gid.GID + ) + + err = rows.Scan(&id, &identityID) + if err != nil { + return nil, fmt.Errorf("cannot scan session authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{"identity_id": identityID.String()} + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate session authorization attributes: %w", err) + } + + return attrsByID, nil } func (s *Session) Insert( diff --git a/pkg/coredata/slack_message.go b/pkg/coredata/slack_message.go index a84a32892..9a09b74c5 100644 --- a/pkg/coredata/slack_message.go +++ b/pkg/coredata/slack_message.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" ) @@ -57,19 +58,42 @@ func (e ErrSlackMessageNotFound) Error() string { return "slack message not found" } -func (sm *SlackMessage) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM slack_messages WHERE id = $1 LIMIT 1;` +func (sm *SlackMessage) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM slack_messages WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, sm.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query slack message authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func NewSlackMessage( diff --git a/pkg/coredata/statement_of_applicability.go b/pkg/coredata/statement_of_applicability.go index a7f414dfa..188e0bcdc 100644 --- a/pkg/coredata/statement_of_applicability.go +++ b/pkg/coredata/statement_of_applicability.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -52,19 +53,42 @@ func (s StatementOfApplicability) CursorKey(orderBy StatementOfApplicabilityOrde panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (s *StatementOfApplicability) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM statements_of_applicability WHERE id = $1 LIMIT 1;` +func (s *StatementOfApplicability) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM statements_of_applicability WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, s.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query statement of applicability authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (s *StatementOfApplicability) LoadByID( diff --git a/pkg/coredata/task.go b/pkg/coredata/task.go index 2e12c6ba9..485ea40cd 100644 --- a/pkg/coredata/task.go +++ b/pkg/coredata/task.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -63,19 +64,42 @@ func (t Task) CursorKey(orderBy TaskOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (t *Task) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM tasks WHERE id = $1 LIMIT 1;` +func (t *Task) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM tasks WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, t.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query task authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (t *Task) LoadByID( diff --git a/pkg/coredata/third_party.go b/pkg/coredata/third_party.go index a2c2d3d12..40b832ba1 100644 --- a/pkg/coredata/third_party.go +++ b/pkg/coredata/third_party.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -181,19 +182,42 @@ func (v ThirdParty) CursorKey(orderBy ThirdPartyOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (v *ThirdParty) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM third_parties WHERE id = $1 LIMIT 1;` +func (v *ThirdParty) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM third_parties WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query thirdParty authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (v *ThirdParty) LoadByID( diff --git a/pkg/coredata/third_party_business_associate_agreement.go b/pkg/coredata/third_party_business_associate_agreement.go index 4c7e75999..db2d23e64 100644 --- a/pkg/coredata/third_party_business_associate_agreement.go +++ b/pkg/coredata/third_party_business_associate_agreement.go @@ -16,7 +16,6 @@ package coredata import ( "context" - "errors" "fmt" "maps" "time" @@ -24,6 +23,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,19 +53,42 @@ func (v ThirdPartyBusinessAssociateAgreement) CursorKey(orderBy ThirdPartyBusine panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (vbaa *ThirdPartyBusinessAssociateAgreement) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM third_party_business_associate_agreements WHERE id = $1 LIMIT 1;` +func (vbaa *ThirdPartyBusinessAssociateAgreement) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM third_party_business_associate_agreements WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, vbaa.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query thirdParty business associate agreement authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (vbaa *ThirdPartyBusinessAssociateAgreement) LoadByThirdPartyID( diff --git a/pkg/coredata/third_party_compliance_report.go b/pkg/coredata/third_party_compliance_report.go index 9331d7b44..28e81df8c 100644 --- a/pkg/coredata/third_party_compliance_report.go +++ b/pkg/coredata/third_party_compliance_report.go @@ -16,7 +16,6 @@ package coredata import ( "context" - "errors" "fmt" "maps" "time" @@ -24,6 +23,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -54,19 +54,42 @@ func (c ThirdPartyComplianceReport) CursorKey(orderBy ThirdPartyComplianceReport panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (v *ThirdPartyComplianceReport) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM third_party_compliance_reports WHERE id = $1 LIMIT 1;` +func (v *ThirdPartyComplianceReport) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM third_party_compliance_reports WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query thirdParty compliance report authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (vcs *ThirdPartyComplianceReports) LoadForThirdPartyID( diff --git a/pkg/coredata/third_party_contact.go b/pkg/coredata/third_party_contact.go index 7d53665e3..6770b3a7c 100644 --- a/pkg/coredata/third_party_contact.go +++ b/pkg/coredata/third_party_contact.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/mail" "go.probo.inc/probo/pkg/page" ) @@ -57,19 +58,42 @@ func (vc ThirdPartyContact) CursorKey(orderBy ThirdPartyContactOrderField) page. panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (vc *ThirdPartyContact) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM third_party_contacts WHERE id = $1 LIMIT 1;` +func (vc *ThirdPartyContact) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM third_party_contacts WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, vc.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query thirdParty contact authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (vc *ThirdPartyContact) LoadByID( diff --git a/pkg/coredata/third_party_data_privacy_agreement.go b/pkg/coredata/third_party_data_privacy_agreement.go index 4b9abc8ee..149946758 100644 --- a/pkg/coredata/third_party_data_privacy_agreement.go +++ b/pkg/coredata/third_party_data_privacy_agreement.go @@ -16,7 +16,6 @@ package coredata import ( "context" - "errors" "fmt" "maps" "time" @@ -24,6 +23,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,19 +53,42 @@ func (v ThirdPartyDataPrivacyAgreement) CursorKey(orderBy ThirdPartyDataPrivacyA panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (vdpa *ThirdPartyDataPrivacyAgreement) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM third_party_data_privacy_agreements WHERE id = $1 LIMIT 1;` +func (vdpa *ThirdPartyDataPrivacyAgreement) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM third_party_data_privacy_agreements WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, vdpa.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query thirdParty data privacy agreement authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (vdpa *ThirdPartyDataPrivacyAgreement) LoadByThirdPartyID( diff --git a/pkg/coredata/third_party_risk_assessment.go b/pkg/coredata/third_party_risk_assessment.go index 8a9f3a318..2488db3ab 100644 --- a/pkg/coredata/third_party_risk_assessment.go +++ b/pkg/coredata/third_party_risk_assessment.go @@ -16,7 +16,6 @@ package coredata import ( "context" - "errors" "fmt" "maps" "time" @@ -24,6 +23,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -55,19 +55,42 @@ func (v ThirdPartyRiskAssessment) CursorKey(orderBy ThirdPartyRiskAssessmentOrde panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (v *ThirdPartyRiskAssessment) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM third_party_risk_assessments WHERE id = $1 LIMIT 1;` +func (v *ThirdPartyRiskAssessment) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM third_party_risk_assessments WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, v.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query thirdParty risk assessment authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } // Insert adds a new risk assessment to the database diff --git a/pkg/coredata/third_party_service.go b/pkg/coredata/third_party_service.go index 024a80f49..b4622789b 100644 --- a/pkg/coredata/third_party_service.go +++ b/pkg/coredata/third_party_service.go @@ -24,6 +24,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -52,19 +53,42 @@ func (vs ThirdPartyService) CursorKey(orderBy ThirdPartyServiceOrderField) page. panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (vs *ThirdPartyService) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM third_party_services WHERE id = $1 LIMIT 1;` +func (vs *ThirdPartyService) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM third_party_services WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, vs.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query thirdParty service authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (vs *ThirdPartyService) LoadByID( diff --git a/pkg/coredata/tracker_pattern.go b/pkg/coredata/tracker_pattern.go index 6d05068a8..5071d5b30 100644 --- a/pkg/coredata/tracker_pattern.go +++ b/pkg/coredata/tracker_pattern.go @@ -26,6 +26,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -79,19 +80,42 @@ func (tp *TrackerPattern) CursorKey(field TrackerPatternOrderField) page.CursorK panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (tp *TrackerPattern) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM tracker_patterns WHERE id = $1 LIMIT 1;` +func (tp *TrackerPattern) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM tracker_patterns WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, tp.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query tracker pattern authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (tp *TrackerPattern) LoadByID( diff --git a/pkg/coredata/tracker_resource.go b/pkg/coredata/tracker_resource.go index 10e3c610f..1fe8ab853 100644 --- a/pkg/coredata/tracker_resource.go +++ b/pkg/coredata/tracker_resource.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -67,19 +68,42 @@ func (tr *TrackerResource) CursorKey(field TrackerResourceOrderField) page.Curso panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (tr *TrackerResource) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM tracker_resources WHERE id = $1 LIMIT 1;` +func (tr *TrackerResource) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM tracker_resources WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, tr.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query tracker resource authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (tr *TrackerResource) LoadByID( diff --git a/pkg/coredata/transfer_impact_assessment.go b/pkg/coredata/transfer_impact_assessment.go index d1f302585..90335c9aa 100644 --- a/pkg/coredata/transfer_impact_assessment.go +++ b/pkg/coredata/transfer_impact_assessment.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -162,19 +163,42 @@ func (tia *TransferImpactAssessment) CursorKey(field TransferImpactAssessmentOrd panic(fmt.Sprintf("unsupported order by: %s", field)) } -func (tia *TransferImpactAssessment) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM processing_activity_transfer_impact_assessments WHERE id = $1 LIMIT 1;` +func (tia *TransferImpactAssessment) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM processing_activity_transfer_impact_assessments WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, tia.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query transfer impact assessment authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (tias *TransferImpactAssessments) CountByOrganizationID( diff --git a/pkg/coredata/trust_center.go b/pkg/coredata/trust_center.go index 8fe219458..e2a64a0da 100644 --- a/pkg/coredata/trust_center.go +++ b/pkg/coredata/trust_center.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -56,19 +57,42 @@ func (tc *TrustCenter) CursorKey(orderBy TrustCenterOrderField) page.CursorKey { panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (tc *TrustCenter) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM trust_centers WHERE id = $1 LIMIT 1;` +func (tc *TrustCenter) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM trust_centers WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, tc.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query trust center authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (tc *TrustCenter) LoadByID( diff --git a/pkg/coredata/trust_center_access.go b/pkg/coredata/trust_center_access.go index 3a8094f0f..3062100ce 100644 --- a/pkg/coredata/trust_center_access.go +++ b/pkg/coredata/trust_center_access.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -52,19 +53,42 @@ func (tca *TrustCenterAccess) CursorKey(orderBy TrustCenterAccessOrderField) pag panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (tca *TrustCenterAccess) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM trust_center_accesses WHERE id = $1 LIMIT 1;` +func (tca *TrustCenterAccess) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM trust_center_accesses WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, tca.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query trust center access authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (tca *TrustCenterAccess) LoadByID( diff --git a/pkg/coredata/trust_center_document_access.go b/pkg/coredata/trust_center_document_access.go index b186cc2e0..7462dfe9b 100644 --- a/pkg/coredata/trust_center_document_access.go +++ b/pkg/coredata/trust_center_document_access.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -53,19 +54,42 @@ func (tcda *TrustCenterDocumentAccess) CursorKey(orderBy TrustCenterDocumentAcce panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (tcda *TrustCenterDocumentAccess) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM trust_center_document_accesses WHERE id = $1 LIMIT 1;` +func (tcda *TrustCenterDocumentAccess) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM trust_center_document_accesses WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, tcda.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query trust center document access authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (tcda *TrustCenterDocumentAccess) LoadByID( diff --git a/pkg/coredata/trust_center_file.go b/pkg/coredata/trust_center_file.go index 077145bbe..2001305ba 100644 --- a/pkg/coredata/trust_center_file.go +++ b/pkg/coredata/trust_center_file.go @@ -16,7 +16,6 @@ package coredata import ( "context" - "errors" "fmt" "maps" "time" @@ -24,6 +23,7 @@ import ( "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -55,19 +55,42 @@ func (t TrustCenterFile) CursorKey(orderBy TrustCenterFileOrderField) page.Curso panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (t *TrustCenterFile) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM trust_center_files WHERE id = $1 LIMIT 1;` +func (t *TrustCenterFile) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM trust_center_files WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, t.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query trust center file authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (t *TrustCenterFile) LoadByID( diff --git a/pkg/coredata/trust_center_reference.go b/pkg/coredata/trust_center_reference.go index 72a9bc510..b2f981d82 100644 --- a/pkg/coredata/trust_center_reference.go +++ b/pkg/coredata/trust_center_reference.go @@ -25,6 +25,7 @@ import ( "github.com/jackc/pgx/v5/pgconn" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -60,19 +61,42 @@ func (t TrustCenterReference) CursorKey(orderBy TrustCenterReferenceOrderField) panic(fmt.Sprintf("unsupported order by: %s", orderBy)) } -func (t *TrustCenterReference) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM trust_center_references WHERE id = $1 LIMIT 1;` +func (t *TrustCenterReference) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM trust_center_references WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, t.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query trust center reference authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (t *TrustCenterReference) LoadByID( diff --git a/pkg/coredata/webhook_subscription.go b/pkg/coredata/webhook_subscription.go index 32050081d..1070fc777 100644 --- a/pkg/coredata/webhook_subscription.go +++ b/pkg/coredata/webhook_subscription.go @@ -26,6 +26,7 @@ import ( "go.probo.inc/probo/pkg/crypto/cipher" "go.probo.inc/probo/pkg/crypto/rand" "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" "go.probo.inc/probo/pkg/page" ) @@ -84,19 +85,42 @@ func (w WebhookSubscription) CursorKey(orderBy WebhookSubscriptionOrderField) pa } // AuthorizationAttributes returns the authorization attributes for policy evaluation. -func (w *WebhookSubscription) AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) { - q := `SELECT organization_id FROM webhook_subscriptions WHERE id = $1 LIMIT 1;` +func (w *WebhookSubscription) AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + q := `SELECT id, organization_id FROM webhook_subscriptions WHERE id = ANY(@resource_ids::text[])` - var organizationID gid.GID - if err := conn.QueryRow(ctx, q, w.ID).Scan(&organizationID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrResourceNotFound - } - - return nil, fmt.Errorf("cannot query webhook subscription authorization attributes: %w", err) + args := pgx.StrictNamedArgs{ + "resource_ids": resourceIDs, } - return map[string]string{"organization_id": organizationID.String()}, nil + rows, err := conn.Query(ctx, q, args) + if err != nil { + return nil, fmt.Errorf("cannot query authorization attributes: %w", err) + } + + defer rows.Close() + + attrsByID := make(policy.AttributesByID) + for rows.Next() { + var id, organizationID gid.GID + err := rows.Scan(&id, &organizationID) + if err != nil { + return nil, fmt.Errorf("cannot scan authorization attributes: %w", err) + } + + attrsByID[id] = policy.Attributes{ + "organization_id": organizationID.String(), + } + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("cannot iterate authorization attributes: %w", err) + } + + return attrsByID, nil } func (w *WebhookSubscription) LoadByID( diff --git a/pkg/iam/authorizer.go b/pkg/iam/authorizer.go index 23392402d..092c07219 100644 --- a/pkg/iam/authorizer.go +++ b/pkg/iam/authorizer.go @@ -16,10 +16,10 @@ package iam import ( "context" - "encoding/json" "errors" "fmt" "maps" + "slices" "strings" "time" @@ -30,10 +30,14 @@ import ( "go.probo.inc/probo/pkg/iam/policy" ) -// AuthorizationAttributer is implemented by entities that provide attributes -// for policy condition evaluation. +// AuthorizationAttributer is implemented by entities that can provide +// authorization attributes for multiple resources in one query. type AuthorizationAttributer interface { - AuthorizationAttributes(ctx context.Context, conn pg.Querier) (map[string]string, error) + AuthorizationAttributes( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, + ) (policy.AttributesByID, error) } // AuthorizeParams contains the parameters for an authorization request. @@ -42,11 +46,40 @@ type AuthorizeParams struct { Resource gid.GID Session *gid.GID Action string - ResourceAttributes map[string]string + ResourceAttributes policy.Attributes DryRun bool SkipAssumptionCheck bool } +// AuthorizeBatchParams contains the parameters for a batch authorization request. +type AuthorizeBatchParams struct { + Principal gid.GID + Session *gid.GID + Action string + Resources []gid.GID + ResourceAttributes policy.Attributes + DryRun bool + SkipAssumptionCheck bool +} + +// MultiAuthorizeItem contains one authorization request in a multi-authorization batch. +// ResourceAttributes are merged on top of the resource attributes loaded +// for the resource before the policy is evaluated. +type MultiAuthorizeItem struct { + Resource gid.GID + Action string + ResourceAttributes policy.Attributes + DryRun bool + SkipAssumptionCheck bool +} + +// AuthorizeMultiParams contains the parameters for a multi-authorization request. +type AuthorizeMultiParams struct { + Principal gid.GID + Session *gid.GID + Items []MultiAuthorizeItem +} + // Authorizer evaluates authorization requests against registered policies. type Authorizer struct { pg *pg.Client @@ -72,58 +105,303 @@ func (a *Authorizer) RegisterPolicySet(ps *PolicySet) { // Authorize checks if the principal is allowed to perform the action on the resource. func (a *Authorizer) Authorize(ctx context.Context, params AuthorizeParams) (*coredata.Scope, error) { + return a.AuthorizeBatch( + ctx, + AuthorizeBatchParams{ + Principal: params.Principal, + Session: params.Session, + Action: params.Action, + Resources: []gid.GID{params.Resource}, + ResourceAttributes: params.ResourceAttributes, + DryRun: params.DryRun, + SkipAssumptionCheck: params.SkipAssumptionCheck, + }, + ) +} + +// AuthorizeBatch checks whether the principal is allowed to perform the action +// on all provided resources. +func (a *Authorizer) AuthorizeBatch(ctx context.Context, params AuthorizeBatchParams) (*coredata.Scope, error) { if params.Principal.EntityType() != coredata.IdentityEntityType { return nil, NewUnsupportedPrincipalTypeError(params.Principal.EntityType()) } - var scope *coredata.Scope + if len(params.Resources) == 0 { + return nil, NewEmptyResourceBatchError(params.Action) + } - if err := a.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - authorizedScope, err := a.authorize(ctx, tx, params) - if err != nil { - return err + expectedEntityType := params.Resources[0].EntityType() + items := make([]MultiAuthorizeItem, 0, len(params.Resources)) + + for _, resourceID := range params.Resources { + if resourceID.EntityType() != expectedEntityType { + entityTypes := make([]uint16, 0, len(params.Resources)) + for _, r := range params.Resources { + entityTypes = append(entityTypes, r.EntityType()) + } + + return nil, NewMixedEntityTypeBatchError( + params.Action, + uniqueSortedEntityTypes(entityTypes), + ) } - scope = authorizedScope - return nil - }); err != nil { + items = append( + items, + MultiAuthorizeItem{ + Resource: resourceID, + Action: params.Action, + DryRun: params.DryRun, + SkipAssumptionCheck: params.SkipAssumptionCheck, + }, + ) + } + + var scope *coredata.Scope + + if err := a.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + authorizedScope, err := a.authorizeMulti( + ctx, + tx, + AuthorizeMultiParams{ + Principal: params.Principal, + Session: params.Session, + Items: items, + }, + params.ResourceAttributes, + ) + if err != nil { + return err + } + + scope = authorizedScope + + return nil + }, + ); err != nil { return nil, err } return scope, nil } -func (a *Authorizer) authorize(ctx context.Context, tx pg.Tx, params AuthorizeParams) (*coredata.Scope, error) { - resourceAttrs, err := a.buildResourceAttributes(ctx, tx, params) - if err != nil { - return nil, fmt.Errorf("cannot build resource attributes: %w", err) +// AuthorizeMulti evaluates each item independently and returns one decision +// per item: a nil entry means allowed, a non-nil entry carries the iam +// error explaining the denial (typically *ErrInsufficientPermissions). +// +// Audit log entries for allowed, non-dry-run items are written in a single +// bulk insert. Use AuthorizeBatch instead when callers want all-or-nothing +// semantics for a homogeneous batch. +func (a *Authorizer) AuthorizeMulti( + ctx context.Context, + params AuthorizeMultiParams, +) (*coredata.Scope, []error, error) { + if params.Principal.EntityType() != coredata.IdentityEntityType { + return nil, nil, NewUnsupportedPrincipalTypeError(params.Principal.EntityType()) } - resourceOrgID := resourceAttrs["organization_id"] + if len(params.Items) == 0 { + return nil, nil, NewEmptyResourceBatchError("") + } + + var ( + scope *coredata.Scope + decisions []error + ) + + if err := a.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + s, itemAttrs, d, err := a.evaluateMultiInTx(ctx, tx, params, nil) + if err != nil { + return err + } + + entries := make(coredata.AuditLogEntries, 0, len(params.Items)) + for i, item := range params.Items { + if d[i] != nil { + continue + } + + entry := a.buildAuditLogEntry( + ctx, + AuthorizeParams{ + Principal: params.Principal, + Resource: item.Resource, + Session: params.Session, + Action: item.Action, + DryRun: item.DryRun, + }, + itemAttrs[i], + ) + if entry == nil { + continue + } + + entries = append(entries, entry) + } + + if len(entries) > 0 { + if err := entries.BulkInsert(ctx, tx, s); err != nil { + a.logger.ErrorCtx( + ctx, + "cannot bulk insert audit log entries", + log.Error(err), + log.String("action", params.Items[0].Action), + ) + } + } + + scope = s + decisions = d + + return nil + }, + ); err != nil { + return nil, nil, err + } + + return scope, decisions, nil +} + +func (a *Authorizer) authorizeMulti( + ctx context.Context, + tx pg.Tx, + params AuthorizeMultiParams, + extraResourceAttributes policy.Attributes, +) (*coredata.Scope, error) { + scope, itemAttrs, decisions, err := a.evaluateMultiInTx( + ctx, + tx, + params, + extraResourceAttributes, + ) + if err != nil { + return nil, err + } + + for _, decision := range decisions { + if decision != nil { + return nil, decision + } + } + + entries := make(coredata.AuditLogEntries, 0, len(params.Items)) + for i, item := range params.Items { + entry := a.buildAuditLogEntry( + ctx, + AuthorizeParams{ + Principal: params.Principal, + Resource: item.Resource, + Session: params.Session, + Action: item.Action, + DryRun: item.DryRun, + }, + itemAttrs[i], + ) + if entry == nil { + continue + } + + entries = append(entries, entry) + } + + if len(entries) > 0 { + if err := entries.BulkInsert(ctx, tx, scope); err != nil { + a.logger.ErrorCtx( + ctx, + "cannot bulk insert audit log entries", + log.Error(err), + log.String("action", params.Items[0].Action), + ) + } + } + + return scope, nil +} + +// evaluateMultiInTx evaluates every item in params against the loaded +// policies and resource attributes. It returns the shared scope, the merged +// per-item resource attributes (used for both evaluation and audit log +// building), and a parallel slice of per-item decisions (nil = allowed). +// Callers decide which decisions to persist to the audit log. +func (a *Authorizer) evaluateMultiInTx( + ctx context.Context, + tx pg.Tx, + params AuthorizeMultiParams, + extraResourceAttributes policy.Attributes, +) (*coredata.Scope, []policy.Attributes, []error, error) { + uniqueResourceIDs := make([]gid.GID, 0, len(params.Items)) + seenResourceIDs := make(map[gid.GID]struct{}, len(params.Items)) + requiresAssumptionCheck := false + + for _, item := range params.Items { + if !item.SkipAssumptionCheck { + requiresAssumptionCheck = true + } + + if _, ok := seenResourceIDs[item.Resource]; ok { + continue + } + + seenResourceIDs[item.Resource] = struct{}{} + uniqueResourceIDs = append(uniqueResourceIDs, item.Resource) + } + + resourceAttrsByResourceID, err := a.buildResourceAttributesBatch( + ctx, + tx, + uniqueResourceIDs, + extraResourceAttributes, + ) + if err != nil { + return nil, nil, nil, fmt.Errorf("cannot build resource attributes batch: %w", err) + } + + actionForErrors := params.Items[0].Action + + resourceOrgID := resourceAttrsByResourceID[uniqueResourceIDs[0]]["organization_id"] + for _, resourceID := range uniqueResourceIDs[1:] { + if resourceAttrsByResourceID[resourceID]["organization_id"] != resourceOrgID { + orgIDs := make([]string, 0, len(uniqueResourceIDs)) + for _, id := range uniqueResourceIDs { + orgIDs = append(orgIDs, resourceAttrsByResourceID[id]["organization_id"]) + } + + return nil, nil, nil, NewMixedOrganizationBatchError( + actionForErrors, + uniqueSortedStrings(orgIDs), + ) + } + } - // Find role for resource's organization membership, err := a.loadMembership(ctx, tx, params.Principal, resourceOrgID) if err != nil { - return nil, fmt.Errorf("cannot load memberships for principal: %w", err) + return nil, nil, nil, fmt.Errorf("cannot load memberships for principal: %w", err) } - // Check whether the viewer is currently assuming the org of the accessed resource - if membership != nil && params.Session != nil && !params.SkipAssumptionCheck { - if _, err := a.getActiveChildSessionForMembership( + // The assumption check is a property of (principal, membership, session), + // so it only runs once even though SkipAssumptionCheck is per-item. + // On failure, ErrAssumptionRequired is recorded only against items that + // did not opt out. + var assumptionErr error + if requiresAssumptionCheck { + err := a.checkAssumption( ctx, tx, - *params.Session, - membership.ID, - ); err != nil { - if _, ok := errors.AsType[*ErrSessionNotFound](err); ok { - return nil, NewAssumptionRequiredError(params.Principal, membership.ID) + params.Principal, + params.Session, + membership, + false, + ) + if err != nil { + if _, ok := errors.AsType[*ErrAssumptionRequired](err); !ok { + return nil, nil, nil, err } - if _, ok := errors.AsType[*ErrSessionExpired](err); ok { - return nil, NewAssumptionRequiredError(params.Principal, membership.ID) - } - - return nil, fmt.Errorf("cannot get active child session for membership: %w", err) + assumptionErr = err } } @@ -132,10 +410,9 @@ func (a *Authorizer) authorize(ctx context.Context, tx pg.Tx, params AuthorizePa role = membership.Role.String() } - // Only set principal.organization_id if they have a role in this org - var scopedPrincipalAttrs map[string]string + var scopedPrincipalAttrs policy.Attributes if membership != nil && role != "" { - scopedPrincipalAttrs = map[string]string{ + scopedPrincipalAttrs = policy.Attributes{ "organization_id": membership.OrganizationID.String(), "role": membership.Role.String(), } @@ -143,7 +420,7 @@ func (a *Authorizer) authorize(ctx context.Context, tx pg.Tx, params AuthorizePa principalAttrs, err := a.buildPrincipalAttributes(ctx, tx, params.Principal, scopedPrincipalAttrs) if err != nil { - return nil, fmt.Errorf("cannot build principal attributes: %w", err) + return nil, nil, nil, fmt.Errorf("cannot build principal attributes: %w", err) } if params.Session != nil { @@ -152,32 +429,162 @@ func (a *Authorizer) authorize(ctx context.Context, tx pg.Tx, params AuthorizePa policies := a.buildPoliciesForRole(role) - req := policy.AuthorizationRequest{ - Principal: params.Principal, - Resource: params.Resource, - Action: params.Action, - ConditionContext: policy.ConditionContext{ - Principal: principalAttrs, - Resource: resourceAttrs, - }, - } + decisions := make([]error, len(params.Items)) + itemAttrs := make([]policy.Attributes, len(params.Items)) + for i, item := range params.Items { + resAttrs := resourceAttrsByResourceID[item.Resource] + if len(item.ResourceAttributes) > 0 { + merged := maps.Clone(resAttrs) + maps.Copy(merged, item.ResourceAttributes) + resAttrs = merged + } + itemAttrs[i] = resAttrs - if a.evaluator.Evaluate(req, policies).IsAllowed() { - scope := coredata.NewScopeFromObjectID(params.Resource) - if resourceOrgID != "" { - orgID, err := gid.ParseGID(resourceOrgID) - if err != nil { - return nil, fmt.Errorf("cannot parse organization id from resource attributes: %w", err) - } - - scope = coredata.NewScope(orgID.TenantID()) + if assumptionErr != nil && !item.SkipAssumptionCheck { + decisions[i] = assumptionErr + continue } - a.recordAuditLog(ctx, tx, params, resourceAttrs) - return scope, nil + req := policy.AuthorizationRequest{ + Principal: params.Principal, + Resource: item.Resource, + Action: item.Action, + ConditionContext: policy.ConditionContext{ + Principal: principalAttrs, + Resource: resAttrs, + }, + } + + if !a.evaluator.Evaluate(req, policies).IsAllowed() { + decisions[i] = NewInsufficientPermissionsError(params.Principal, item.Resource, item.Action) + } } - return nil, NewInsufficientPermissionsError(params.Principal, params.Resource, params.Action) + scope := coredata.NewScopeFromObjectID(uniqueResourceIDs[0]) + + if resourceOrgID != "" { + orgID, _ := gid.ParseGID(resourceOrgID) + scope = coredata.NewScope(orgID.TenantID()) + } + + return scope, itemAttrs, decisions, nil +} + +func (a *Authorizer) buildResourceAttributesBatch( + ctx context.Context, + conn pg.Querier, + uniqueResourceIDs []gid.GID, + extraResourceAttributes policy.Attributes, +) (policy.AttributesByID, error) { + resourceAttrsByID, err := a.loadResourceAttributesByType(ctx, conn, uniqueResourceIDs) + if err != nil { + return nil, err + } + + resourceAttrsByResourceID := make(policy.AttributesByID, len(uniqueResourceIDs)) + for _, resourceID := range uniqueResourceIDs { + resourceAttrs := resourceAttrsByID[resourceID] + + attrs := policy.Attributes{ + "id": resourceID.String(), + } + maps.Copy(attrs, resourceAttrs) + + if extraResourceAttributes != nil { + maps.Copy(attrs, extraResourceAttributes) + } + + resourceAttrsByResourceID[resourceID] = attrs + } + + return resourceAttrsByResourceID, nil +} + +func (a *Authorizer) loadResourceAttributesByType( + ctx context.Context, + conn pg.Querier, + resourceIDs []gid.GID, +) (policy.AttributesByID, error) { + resourceIDsByEntityType := make(map[uint16][]gid.GID) + orderedEntityTypes := make([]uint16, 0, len(resourceIDs)) + + for _, resourceID := range resourceIDs { + entityType := resourceID.EntityType() + + if _, ok := resourceIDsByEntityType[entityType]; !ok { + orderedEntityTypes = append(orderedEntityTypes, entityType) + } + + resourceIDsByEntityType[entityType] = append(resourceIDsByEntityType[entityType], resourceID) + } + + resourceAttrsByID := make(policy.AttributesByID, len(resourceIDs)) + + for _, entityType := range orderedEntityTypes { + groupResourceIDs := resourceIDsByEntityType[entityType] + + entity, ok := coredata.NewEntityFromID(groupResourceIDs[0]) + if !ok { + return nil, fmt.Errorf("unsupported resource type: %d", groupResourceIDs[0].EntityType()) + } + + attributer, ok := entity.(AuthorizationAttributer) + if !ok { + return nil, NewBatchAuthorizationUnsupportedResourceTypeError(entityType) + } + + groupAttrsByID, err := attributer.AuthorizationAttributes(ctx, conn, groupResourceIDs) + if err != nil { + return nil, fmt.Errorf( + "cannot load batched resource attributes for entity type %d: %w", + entityType, + err, + ) + } + + for _, resourceID := range groupResourceIDs { + resourceAttrs, ok := groupAttrsByID[resourceID] + if !ok { + return nil, coredata.ErrResourceNotFound + } + + resourceAttrsByID[resourceID] = resourceAttrs + } + } + + return resourceAttrsByID, nil +} + +func (a *Authorizer) checkAssumption( + ctx context.Context, + tx pg.Tx, + principalID gid.GID, + sessionID *gid.GID, + membership *coredata.Membership, + skipAssumptionCheck bool, +) error { + if membership == nil || sessionID == nil || skipAssumptionCheck { + return nil + } + + if _, err := a.getActiveChildSessionForMembership( + ctx, + tx, + *sessionID, + membership.ID, + ); err != nil { + if _, ok := errors.AsType[*ErrSessionNotFound](err); ok { + return NewAssumptionRequiredError(principalID, membership.ID) + } + + if _, ok := errors.AsType[*ErrSessionExpired](err); ok { + return NewAssumptionRequiredError(principalID, membership.ID) + } + + return fmt.Errorf("cannot get active child session for membership: %w", err) + } + + return nil } func (a *Authorizer) loadMembership( @@ -234,55 +641,30 @@ func (a *Authorizer) buildPrincipalAttributes( ctx context.Context, conn pg.Querier, principalID gid.GID, - defaultAttrs map[string]string, -) (map[string]string, error) { - attrs := map[string]string{ + defaultAttrs policy.Attributes, +) (policy.Attributes, error) { + attrs := policy.Attributes{ "id": principalID.String(), } maps.Copy(attrs, defaultAttrs) if entity, ok := coredata.NewEntityFromID(principalID); ok { - if attributer, ok := entity.(AuthorizationAttributer); ok { - entityAttrs, err := attributer.AuthorizationAttributes(ctx, conn) - if err != nil { - return nil, fmt.Errorf("cannot load principal attributes: %w", err) - } - - maps.Copy(attrs, entityAttrs) + attributer, ok := entity.(AuthorizationAttributer) + if !ok { + return nil, NewBatchAuthorizationUnsupportedResourceTypeError(principalID.EntityType()) } - } - return attrs, nil -} + entityAttrsByID, err := attributer.AuthorizationAttributes(ctx, conn, []gid.GID{principalID}) + if err != nil { + return nil, fmt.Errorf("cannot load principal attributes: %w", err) + } -func (a *Authorizer) buildResourceAttributes( - ctx context.Context, - conn pg.Querier, - params AuthorizeParams, -) (map[string]string, error) { - attrs := map[string]string{ - "id": params.Resource.String(), - } + entityAttrs, ok := entityAttrsByID[principalID] + if !ok { + return nil, coredata.ErrResourceNotFound + } - entity, ok := coredata.NewEntityFromID(params.Resource) - if !ok { - return nil, fmt.Errorf("unsupported resource type: %d", params.Resource.EntityType()) - } - - attributer, ok := entity.(AuthorizationAttributer) - if !ok { - return nil, fmt.Errorf("resource %d does not implement AuthorizationAttributer", params.Resource.EntityType()) - } - - entityAttrs, err := attributer.AuthorizationAttributes(ctx, conn) - if err != nil { - return nil, fmt.Errorf("cannot load resource attributes: %w", err) - } - - maps.Copy(attrs, entityAttrs) - - if params.ResourceAttributes != nil { - maps.Copy(attrs, params.ResourceAttributes) + maps.Copy(attrs, entityAttrs) } return attrs, nil @@ -298,9 +680,44 @@ func (a *Authorizer) buildPoliciesForRole(role string) []*policy.Policy { return policies } -// resourceTypeFromAction extracts the resource type name from an action -// string. For example, "core:thirdParty:create" returns "ThirdParty" and -// "core:webhook-subscription:delete" returns "WebhookSubscription". +func uniqueSortedStrings(values []string) []string { + set := make(map[string]struct{}, len(values)) + unique := make([]string, 0, len(values)) + + for _, value := range values { + if _, ok := set[value]; ok { + continue + } + + set[value] = struct{}{} + unique = append(unique, value) + } + + slices.Sort(unique) + + return unique +} + +func uniqueSortedEntityTypes(values []uint16) []uint16 { + set := make(map[uint16]struct{}, len(values)) + unique := make([]uint16, 0, len(values)) + + for _, value := range values { + if _, ok := set[value]; ok { + continue + } + + set[value] = struct{}{} + unique = append(unique, value) + } + + slices.Sort(unique) + + return unique +} + +// resourceTypeFromAction extracts the PascalCase resource type from an +// action string, e.g. "core:webhook-subscription:delete" -> "WebhookSubscription". func resourceTypeFromAction(action string) string { parts := strings.Split(action, ":") if len(parts) < 3 { @@ -317,19 +734,20 @@ func resourceTypeFromAction(action string) string { return strings.Join(segments, "") } -func (a *Authorizer) recordAuditLog( +// buildAuditLogEntry returns nil when no entry should be recorded +// (dry run or missing/invalid organization id). +func (a *Authorizer) buildAuditLogEntry( ctx context.Context, - tx pg.Tx, params AuthorizeParams, - resourceAttrs map[string]string, -) { + resourceAttrs policy.Attributes, +) *coredata.AuditLogEntry { if params.DryRun { - return + return nil } orgIDStr := resourceAttrs["organization_id"] if orgIDStr == "" { - return + return nil } orgID, err := gid.ParseGID(orgIDStr) @@ -340,7 +758,7 @@ func (a *Authorizer) recordAuditLog( log.Error(err), ) - return + return nil } var actorType coredata.AuditLogActorType @@ -352,18 +770,9 @@ func (a *Authorizer) recordAuditLog( resourceType := resourceTypeFromAction(params.Action) - metadata, err := json.Marshal(map[string]any{}) - if err != nil { - a.logger.ErrorCtx( - ctx, - "cannot marshal audit log metadata", - log.Error(err), - ) + metadata := []byte("{}") - return - } - - entry := &coredata.AuditLogEntry{ + return &coredata.AuditLogEntry{ ID: gid.New(orgID.TenantID(), coredata.AuditLogEntryEntityType), OrganizationID: orgID, ActorID: params.Principal, @@ -374,16 +783,4 @@ func (a *Authorizer) recordAuditLog( Metadata: metadata, CreatedAt: time.Now(), } - - scope := coredata.NewScope(orgID.TenantID()) - - if err := entry.Insert(ctx, tx, scope); err != nil { - a.logger.ErrorCtx( - ctx, - "cannot insert audit log entry", - log.Error(err), - log.String("action", params.Action), - log.String("resource_id", params.Resource.String()), - ) - } } diff --git a/pkg/iam/authorizer_batch_test.go b/pkg/iam/authorizer_batch_test.go new file mode 100644 index 000000000..2c8e40969 --- /dev/null +++ b/pkg/iam/authorizer_batch_test.go @@ -0,0 +1,1104 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package iam_test + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/policy" + "go.probo.inc/probo/pkg/mail" +) + +const testPgDSNEnvVar = "PROBO_TEST_PG_URL" + +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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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.OAuth2AccessTokenEntityType), + }, + }, + ) + require.Error(t, err) + + errUnsupported, ok := errors.AsType[*iam.ErrBatchAuthorizationUnsupportedResourceType](err) + require.True(t, ok) + assert.Equal(t, coredata.OAuth2AccessTokenEntityType, errUnsupported.EntityType) + }) + + t.Run("single deny rolls back entire batch", func(t *testing.T) { + t.Parallel() + + client := newTestPgClient(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 := newTestPgClient(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))) + + _, 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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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 := newTestPgClient(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))) + + 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))) + + 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 := newTestPgClient(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 := newTestPgClient(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))) + 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))) + 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()) +} + +func newTestPgClient(t *testing.T) *pg.Client { + t.Helper() + + dsn := os.Getenv(testPgDSNEnvVar) + if dsn == "" { + t.Skipf("skipping: %s not set (requires a migrated test database)", testPgDSNEnvVar) + } + + u, err := url.Parse(dsn) + require.NoError(t, err, "invalid %s value", testPgDSNEnvVar) + + opts := []pg.Option{ + pg.WithRegisterer(prometheus.NewRegistry()), + } + + if u.Host != "" { + host := u.Host + if u.Port() == "" { + host = net.JoinHostPort(u.Hostname(), "5432") + } + + opts = append(opts, pg.WithAddr(host)) + } + + if u.User != nil { + opts = append(opts, pg.WithUser(u.User.Username())) + if password, ok := u.User.Password(); ok { + opts = append(opts, pg.WithPassword(password)) + } + } + + if len(u.Path) > 1 { + opts = append(opts, pg.WithDatabase(u.Path[1:])) + } + + client, err := pg.NewClient(opts...) + require.NoError(t, err) + + t.Cleanup(func() { + client.Close() + }) + + return client +} diff --git a/pkg/iam/authorizer_unit_test.go b/pkg/iam/authorizer_unit_test.go new file mode 100644 index 000000000..1ad01187b --- /dev/null +++ b/pkg/iam/authorizer_unit_test.go @@ -0,0 +1,489 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package iam + +import ( + "context" + "errors" + "io" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" +) + +func TestAuthorizer_ValidateInputs(t *testing.T) { + t.Parallel() + + t.Run("authorize rejects unsupported principal type", func(t *testing.T) { + t.Parallel() + + a := &Authorizer{} + _, err := a.Authorize( + context.Background(), + AuthorizeParams{ + Principal: gid.New(gid.NewTenantID(), coredata.OrganizationEntityType), + }, + ) + require.Error(t, err) + + errUnsupported, ok := errors.AsType[*ErrUnsupportedPrincipalType](err) + require.True(t, ok) + assert.Equal(t, coredata.OrganizationEntityType, errUnsupported.EntityType) + }) + + t.Run("authorize batch rejects unsupported principal type", func(t *testing.T) { + t.Parallel() + + a := &Authorizer{} + _, err := a.AuthorizeBatch( + context.Background(), + AuthorizeBatchParams{ + Principal: gid.New(gid.NewTenantID(), coredata.OrganizationEntityType), + Resources: []gid.GID{gid.New(gid.NewTenantID(), coredata.FrameworkEntityType)}, + }, + ) + require.Error(t, err) + + errUnsupported, ok := errors.AsType[*ErrUnsupportedPrincipalType](err) + require.True(t, ok) + assert.Equal(t, coredata.OrganizationEntityType, errUnsupported.EntityType) + }) + + t.Run("authorize batch rejects empty resources", func(t *testing.T) { + t.Parallel() + + a := &Authorizer{} + _, err := a.AuthorizeBatch( + context.Background(), + AuthorizeBatchParams{ + Principal: gid.New(gid.NilTenant, coredata.IdentityEntityType), + }, + ) + require.Error(t, err) + _, ok := errors.AsType[*ErrEmptyResourceBatch](err) + require.True(t, ok) + }) + +} + +func TestAuthorizer_InternalErrorPaths(t *testing.T) { + t.Parallel() + + ctx := context.Background() + identityID := gid.New(gid.NilTenant, coredata.IdentityEntityType) + unknownResourceID := gid.New(gid.NewTenantID(), 65535) + unsupportedResourceID := gid.New(gid.NewTenantID(), coredata.OAuth2AccessTokenEntityType) + + a := &Authorizer{ + evaluator: policy.NewEvaluator(), + policySet: NewPolicySet(), + } + + t.Run("authorize batch returns wrapped resource attributes batch error", func(t *testing.T) { + t.Parallel() + + _, err := a.authorizeMulti( + ctx, + nil, + AuthorizeMultiParams{ + Principal: identityID, + Items: []MultiAuthorizeItem{ + { + Resource: unknownResourceID, + Action: "core:test:list", + }, + }, + }, + nil, + ) + require.Error(t, err) + assert.ErrorContains(t, err, "cannot build resource attributes batch") + }) + + t.Run("authorize batch rejects mixed entity types", func(t *testing.T) { + t.Parallel() + + firstResourceID := gid.New(gid.NewTenantID(), coredata.FrameworkEntityType) + secondResourceID := gid.New(gid.NewTenantID(), coredata.OrganizationEntityType) + + _, err := a.AuthorizeBatch( + ctx, + AuthorizeBatchParams{ + Principal: identityID, + Action: "core:test:list", + Resources: []gid.GID{firstResourceID, secondResourceID}, + }, + ) + require.Error(t, err) + + errMixedEntityType, ok := errors.AsType[*ErrMixedEntityTypeBatch](err) + require.True(t, ok) + assert.Equal( + t, + []uint16{coredata.OrganizationEntityType, coredata.FrameworkEntityType}, + errMixedEntityType.EntityTypes, + ) + }) + + t.Run("build principal attributes rejects unsupported batch interface type", func(t *testing.T) { + t.Parallel() + + _, err := a.buildPrincipalAttributes( + ctx, + nil, + unsupportedResourceID, + map[string]string{"role": "OWNER"}, + ) + require.Error(t, err) + errUnsupported, ok := errors.AsType[*ErrBatchAuthorizationUnsupportedResourceType](err) + require.True(t, ok) + assert.Equal(t, coredata.OAuth2AccessTokenEntityType, errUnsupported.EntityType) + }) + + t.Run("build principal attributes keeps defaults when entity type is unknown", func(t *testing.T) { + t.Parallel() + + attrs, err := a.buildPrincipalAttributes( + ctx, + nil, + unknownResourceID, + map[string]string{"role": "OWNER"}, + ) + require.NoError(t, err) + assert.Equal(t, unknownResourceID.String(), attrs["id"]) + assert.Equal(t, "OWNER", attrs["role"]) + }) +} + +func TestAuthorizer_HelperMethods(t *testing.T) { + t.Parallel() + + t.Run("check assumption short-circuits", func(t *testing.T) { + t.Parallel() + + a := &Authorizer{} + identityID := gid.New(gid.NilTenant, coredata.IdentityEntityType) + sessionID := gid.New(gid.NilTenant, coredata.SessionEntityType) + membership := &coredata.Membership{ID: gid.New(gid.NewTenantID(), coredata.MembershipEntityType)} + + require.NoError(t, a.checkAssumption(context.Background(), nil, identityID, nil, membership, false)) + require.NoError(t, a.checkAssumption(context.Background(), nil, identityID, &sessionID, nil, false)) + require.NoError(t, a.checkAssumption(context.Background(), nil, identityID, &sessionID, membership, true)) + }) + + t.Run("build policies for role includes identity scoped policies", func(t *testing.T) { + t.Parallel() + + a := &Authorizer{ + policySet: NewPolicySet(). + AddIdentityScopedPolicy(policy.NewPolicy("identity", "Identity", policy.Allow("identity:read"))). + AddRolePolicy("OWNER", policy.NewPolicy("owner", "Owner", policy.Allow("core:*"))), + } + + withRole := a.buildPoliciesForRole("OWNER") + require.Len(t, withRole, 2) + + withoutRole := a.buildPoliciesForRole("VIEWER") + require.Len(t, withoutRole, 1) + }) + + t.Run("unique sorted strings deduplicates and sorts", func(t *testing.T) { + t.Parallel() + + got := uniqueSortedStrings([]string{"b", "a", "b", "", "c", "a"}) + assert.Equal(t, []string{"", "a", "b", "c"}, got) + }) + + t.Run("unique sorted entity types deduplicates and sorts", func(t *testing.T) { + t.Parallel() + + got := uniqueSortedEntityTypes( + []uint16{ + coredata.FrameworkEntityType, + coredata.OrganizationEntityType, + coredata.FrameworkEntityType, + coredata.OrganizationEntityType, + }, + ) + assert.Equal( + t, + []uint16{coredata.OrganizationEntityType, coredata.FrameworkEntityType}, + got, + ) + }) + + t.Run("resource type from action parses segments", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, "ThirdParty", resourceTypeFromAction("core:third-party:get")) + assert.Equal(t, "WebhookSubscription", resourceTypeFromAction("core:webhook-subscription:delete")) + assert.Equal(t, "Unknown", resourceTypeFromAction("invalid-action")) + }) +} + +func TestAuthorizer_LoadMembership(t *testing.T) { + t.Parallel() + + a := &Authorizer{} + + t.Run("empty organization id returns nil membership", func(t *testing.T) { + t.Parallel() + + membership, err := a.loadMembership( + context.Background(), + nil, + gid.New(gid.NilTenant, coredata.IdentityEntityType), + "", + ) + require.NoError(t, err) + assert.Nil(t, membership) + }) + + t.Run("invalid organization id returns parse error", func(t *testing.T) { + t.Parallel() + + _, err := a.loadMembership( + context.Background(), + nil, + gid.New(gid.NilTenant, coredata.IdentityEntityType), + "not-a-gid", + ) + require.Error(t, err) + assert.ErrorContains(t, err, "cannot parse gid") + }) +} + +func TestAuthorizer_BuildAuditLogEntry(t *testing.T) { + t.Parallel() + + tenantID := gid.NewTenantID() + orgID := gid.New(tenantID, coredata.OrganizationEntityType) + principalID := gid.New(gid.NilTenant, coredata.IdentityEntityType) + resourceID := gid.New(tenantID, coredata.FrameworkEntityType) + sessionID := gid.New(gid.NilTenant, coredata.SessionEntityType) + + tests := []struct { + name string + params AuthorizeParams + resourceAttrs policy.Attributes + wantNil bool + wantActorType coredata.AuditLogActorType + }{ + { + name: "dry run returns nil", + params: AuthorizeParams{ + Principal: principalID, + Resource: resourceID, + Action: "core:framework:get", + DryRun: true, + }, + resourceAttrs: policy.Attributes{ + "organization_id": orgID.String(), + }, + wantNil: true, + }, + { + name: "missing organization id returns nil", + params: AuthorizeParams{ + Principal: principalID, + Resource: resourceID, + Action: "core:framework:get", + }, + resourceAttrs: policy.Attributes{ + "id": resourceID.String(), + }, + wantNil: true, + }, + { + name: "unparseable organization id returns nil", + params: AuthorizeParams{ + Principal: principalID, + Resource: resourceID, + Action: "core:framework:get", + }, + resourceAttrs: policy.Attributes{ + "organization_id": "invalid-gid", + }, + wantNil: true, + }, + { + name: "session present sets user actor type", + params: AuthorizeParams{ + Principal: principalID, + Resource: resourceID, + Action: "core:framework:get", + Session: &sessionID, + }, + resourceAttrs: policy.Attributes{ + "organization_id": orgID.String(), + }, + wantActorType: coredata.AuditLogActorTypeUser, + }, + { + name: "nil session sets api key actor type", + params: AuthorizeParams{ + Principal: principalID, + Resource: resourceID, + Action: "core:framework:get", + }, + resourceAttrs: policy.Attributes{ + "organization_id": orgID.String(), + }, + wantActorType: coredata.AuditLogActorTypeAPIKey, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + a := &Authorizer{ + logger: log.NewLogger(log.WithOutput(io.Discard)), + } + + entry := a.buildAuditLogEntry(context.Background(), tt.params, tt.resourceAttrs) + if tt.wantNil { + assert.Nil(t, entry) + + return + } + + require.NotNil(t, entry) + assert.Equal(t, tt.wantActorType, entry.ActorType) + assert.Equal(t, tt.params.Principal, entry.ActorID) + assert.Equal(t, tt.params.Resource, entry.ResourceID) + assert.Equal(t, tt.params.Action, entry.Action) + }) + } +} + +func TestAuthorizer_WrappedInternalErrors(t *testing.T) { + t.Parallel() + + a := &Authorizer{ + logger: log.NewLogger(log.WithOutput(io.Discard)), + } + + queryErr := errors.New("query failed") + tx := &errorTx{queryErr: queryErr} + principalID := gid.New(gid.NilTenant, coredata.IdentityEntityType) + membershipID := gid.New(gid.NewTenantID(), coredata.MembershipEntityType) + sessionID := gid.New(gid.NilTenant, coredata.SessionEntityType) + resourceOrgID := gid.New(gid.NewTenantID(), coredata.OrganizationEntityType).String() + + t.Run("load membership wraps load errors", func(t *testing.T) { + t.Parallel() + + _, err := a.loadMembership(context.Background(), tx, principalID, resourceOrgID) + require.Error(t, err) + assert.ErrorContains(t, err, "cannot load active membership") + }) + + t.Run("get active child session wraps load errors", func(t *testing.T) { + t.Parallel() + + _, err := a.getActiveChildSessionForMembership( + context.Background(), + tx, + sessionID, + membershipID, + ) + require.Error(t, err) + assert.ErrorContains(t, err, "cannot load child session") + }) + + t.Run("check assumption wraps non-assumption errors", func(t *testing.T) { + t.Parallel() + + err := a.checkAssumption( + context.Background(), + tx, + principalID, + &sessionID, + &coredata.Membership{ID: membershipID}, + false, + ) + require.Error(t, err) + assert.ErrorContains(t, err, "cannot get active child session for membership") + + _, ok := errors.AsType[*ErrAssumptionRequired](err) + assert.False(t, ok) + }) + + t.Run("build principal attributes wraps load errors", func(t *testing.T) { + t.Parallel() + + _, err := a.buildPrincipalAttributes(context.Background(), tx, principalID, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "cannot load principal attributes") + }) + + t.Run("load resource attributes by type wraps load errors", func(t *testing.T) { + t.Parallel() + + resourceID := gid.New(gid.NewTenantID(), coredata.FrameworkEntityType) + + _, err := a.loadResourceAttributesByType(context.Background(), tx, []gid.GID{resourceID}) + require.Error(t, err) + assert.ErrorContains(t, err, "cannot load batched resource attributes") + }) +} + +type errorRow struct { + err error +} + +func (r *errorRow) Scan(...any) error { + return r.err +} + +type errorTx struct { + queryErr error +} + +var _ pg.Tx = (*errorTx)(nil) + +func (tx *errorTx) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) { + return pgconn.CommandTag{}, tx.queryErr +} + +func (tx *errorTx) Query(context.Context, string, ...any) (pgx.Rows, error) { + return nil, tx.queryErr +} + +func (tx *errorTx) QueryRow(context.Context, string, ...any) pgx.Row { + return &errorRow{err: tx.queryErr} +} + +func (tx *errorTx) CopyFrom(context.Context, pgx.Identifier, []string, pgx.CopyFromSource) (int64, error) { + return 0, tx.queryErr +} + +func (tx *errorTx) SendBatch(context.Context, *pgx.Batch) pgx.BatchResults { + return nil +} + +func (tx *errorTx) Savepoint(context.Context, pg.ExecFunc[pg.Tx]) error { + return tx.queryErr +}