Add access review domain services
Add AccessSourceService, AccessEntryService, CampaignService, and ReviewEngine in the accessreview package. Service exposes tenant-scoped sub-service accessors and an unscoped ResolveEntryOrganizationID. Register access review actions and policies. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
378
pkg/accessreview/review_engine.go
Normal file
378
pkg/accessreview/review_engine.go
Normal file
@@ -0,0 +1,378 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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 accessreview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/accessreview/drivers"
|
||||
"go.probo.inc/probo/pkg/connector"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/crypto/cipher"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
// ReviewEngine contains the stateless core logic for access review campaigns:
|
||||
// snapshot and source data collection.
|
||||
type ReviewEngine struct {
|
||||
pg *pg.Client
|
||||
scope coredata.Scoper
|
||||
encryptionKey cipher.EncryptionKey
|
||||
connectorRegistry *connector.ConnectorRegistry
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewReviewEngine(
|
||||
pgClient *pg.Client,
|
||||
scope coredata.Scoper,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
connectorRegistry *connector.ConnectorRegistry,
|
||||
logger *log.Logger,
|
||||
) *ReviewEngine {
|
||||
return &ReviewEngine{
|
||||
pg: pgClient,
|
||||
scope: scope,
|
||||
encryptionKey: encryptionKey,
|
||||
connectorRegistry: connectorRegistry,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchSource pulls accounts from a single source and upserts access entries.
|
||||
func (e *ReviewEngine) FetchSource(
|
||||
ctx context.Context,
|
||||
campaign *coredata.AccessReviewCampaign,
|
||||
sourceID gid.GID,
|
||||
) (int, error) {
|
||||
fetchedCount := 0
|
||||
|
||||
// Resolve the driver and load baseline data outside the write transaction
|
||||
// so that external HTTP calls do not hold a database connection.
|
||||
var (
|
||||
source *coredata.AccessSource
|
||||
driver drivers.Driver
|
||||
baseline []coredata.BaselineAccountEntry
|
||||
)
|
||||
|
||||
err := e.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
source = &coredata.AccessSource{}
|
||||
if err := source.LoadByID(ctx, conn, e.scope, sourceID); err != nil {
|
||||
return fmt.Errorf("cannot load access source %s: %w", sourceID, err)
|
||||
}
|
||||
if source.OrganizationID != campaign.OrganizationID {
|
||||
return fmt.Errorf("cannot process access source: %s does not belong to campaign organization", sourceID)
|
||||
}
|
||||
|
||||
var err error
|
||||
driver, err = e.resolveDriver(ctx, conn, source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve driver for source %s: %w", source.Name, err)
|
||||
}
|
||||
|
||||
lastCompletedCampaign := &coredata.AccessReviewCampaign{}
|
||||
if err := lastCompletedCampaign.LoadLastCompletedByOrganizationID(ctx, conn, e.scope, campaign.OrganizationID); err != nil {
|
||||
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return fmt.Errorf("cannot load last completed campaign: %w", err)
|
||||
}
|
||||
} else {
|
||||
entries := &coredata.AccessEntries{}
|
||||
baseline, err = entries.LoadBaselineBySourceID(ctx, conn, e.scope, lastCompletedCampaign.ID, sourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load baseline entries by source: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
previousByAccountKey := make(map[string]coredata.BaselineAccountEntry, len(baseline))
|
||||
for _, entry := range baseline {
|
||||
previousByAccountKey[entry.AccountKey] = entry
|
||||
}
|
||||
|
||||
sourceCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
accounts, err := driver.ListAccounts(sourceCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot list accounts from source %s: %w", source.Name, err)
|
||||
}
|
||||
fetchedCount = len(accounts)
|
||||
|
||||
err = e.pg.WithTx(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
now := time.Now()
|
||||
seenAccountKeys := make(map[string]struct{}, len(accounts))
|
||||
|
||||
for _, account := range accounts {
|
||||
accountKey := normalizeAccountKey(account.Email, account.ExternalID)
|
||||
seenAccountKeys[accountKey] = struct{}{}
|
||||
incrementalTag := coredata.AccessEntryIncrementalTagNew
|
||||
if _, ok := previousByAccountKey[accountKey]; ok {
|
||||
incrementalTag = coredata.AccessEntryIncrementalTagUnchanged
|
||||
}
|
||||
|
||||
entry := &coredata.AccessEntry{
|
||||
ID: gid.New(e.scope.GetTenantID(), coredata.AccessEntryEntityType),
|
||||
OrganizationID: campaign.OrganizationID,
|
||||
AccessReviewCampaignID: campaign.ID,
|
||||
AccessSourceID: sourceID,
|
||||
Email: account.Email,
|
||||
FullName: account.FullName,
|
||||
Role: account.Role,
|
||||
JobTitle: account.JobTitle,
|
||||
IsAdmin: account.IsAdmin,
|
||||
MFAStatus: account.MFAStatus,
|
||||
AuthMethod: account.AuthMethod,
|
||||
AccountType: account.AccountType,
|
||||
LastLogin: account.LastLogin,
|
||||
AccountCreatedAt: account.CreatedAt,
|
||||
ExternalID: account.ExternalID,
|
||||
AccountKey: accountKey,
|
||||
IncrementalTag: incrementalTag,
|
||||
Flags: []coredata.AccessEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessEntryDecisionPending,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := entry.Upsert(ctx, conn, e.scope); err != nil {
|
||||
return fmt.Errorf("cannot upsert access entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create REMOVED entries for accounts that existed in the previous
|
||||
// campaign but are no longer present in the current fetch.
|
||||
for accountKey, prev := range previousByAccountKey {
|
||||
if _, seen := seenAccountKeys[accountKey]; seen {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := &coredata.AccessEntry{
|
||||
ID: gid.New(e.scope.GetTenantID(), coredata.AccessEntryEntityType),
|
||||
OrganizationID: campaign.OrganizationID,
|
||||
AccessReviewCampaignID: campaign.ID,
|
||||
AccessSourceID: sourceID,
|
||||
Email: prev.Email,
|
||||
FullName: prev.FullName,
|
||||
AccountKey: accountKey,
|
||||
IncrementalTag: coredata.AccessEntryIncrementalTagRemoved,
|
||||
Flags: []coredata.AccessEntryFlag{},
|
||||
FlagReasons: []string{},
|
||||
Decision: coredata.AccessEntryDecisionPending,
|
||||
MFAStatus: coredata.MFAStatusUnknown,
|
||||
AuthMethod: coredata.AccessEntryAuthMethodUnknown,
|
||||
AccountType: coredata.AccessEntryAccountTypeUser,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := entry.Upsert(ctx, conn, e.scope); err != nil {
|
||||
return fmt.Errorf("cannot upsert removed access entry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return fetchedCount, nil
|
||||
}
|
||||
|
||||
func normalizeAccountKey(email, externalID string) string {
|
||||
emailKey := strings.ToLower(strings.TrimSpace(email))
|
||||
externalID = strings.TrimSpace(externalID)
|
||||
if externalID != "" {
|
||||
return emailKey + "|" + externalID
|
||||
}
|
||||
|
||||
return emailKey
|
||||
}
|
||||
|
||||
// oauthClient returns an HTTP client for an OAuth2 connection, using
|
||||
// RefreshableClient when a refresh config is available for the provider.
|
||||
func (e *ReviewEngine) oauthClient(
|
||||
ctx context.Context,
|
||||
conn *connector.OAuth2Connection,
|
||||
provider coredata.ConnectorProvider,
|
||||
) (*http.Client, error) {
|
||||
if e.connectorRegistry != nil {
|
||||
refreshCfg := e.connectorRegistry.GetOAuth2RefreshConfig(string(provider))
|
||||
if refreshCfg != nil {
|
||||
return conn.RefreshableClient(ctx, *refreshCfg)
|
||||
}
|
||||
}
|
||||
return conn.Client(ctx)
|
||||
}
|
||||
|
||||
// connectorHTTPClient returns an HTTP client for the given connector.
|
||||
// For OAuth2 connections it delegates to oauthClient so that token refresh
|
||||
// is handled transparently. For other connection types it falls back to
|
||||
// the standard Client method.
|
||||
func (e *ReviewEngine) connectorHTTPClient(
|
||||
ctx context.Context,
|
||||
dbConnector *coredata.Connector,
|
||||
) (*http.Client, error) {
|
||||
if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok {
|
||||
return e.oauthClient(ctx, oauth2Conn, dbConnector.Provider)
|
||||
}
|
||||
return dbConnector.Connection.Client(ctx)
|
||||
}
|
||||
|
||||
// resolveDriver creates a Driver for the given AccessSource based on
|
||||
// connector_id (null = built-in, set = connector-backed).
|
||||
func (e *ReviewEngine) resolveDriver(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
source *coredata.AccessSource,
|
||||
) (drivers.Driver, error) {
|
||||
if source.ConnectorID == nil {
|
||||
// CSV-backed source: use CSVDriver when csv_data is present
|
||||
if source.CsvData != nil && *source.CsvData != "" {
|
||||
return drivers.NewCSVDriver(strings.NewReader(*source.CsvData)), nil
|
||||
}
|
||||
|
||||
// Built-in driver: default to ProboMemberships
|
||||
return drivers.NewProboMembershipsDriver(e.pg, e.scope, source.OrganizationID), nil
|
||||
}
|
||||
|
||||
// Connector-backed: look up the connector and resolve driver by provider
|
||||
dbConnector := &coredata.Connector{}
|
||||
if err := dbConnector.LoadByID(ctx, conn, e.scope, *source.ConnectorID, e.encryptionKey); err != nil {
|
||||
return nil, fmt.Errorf("cannot load connector %s: %w", *source.ConnectorID, err)
|
||||
}
|
||||
|
||||
// Capture token before refresh to detect changes.
|
||||
var tokenBefore string
|
||||
if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok {
|
||||
tokenBefore = oauth2Conn.AccessToken
|
||||
}
|
||||
|
||||
// Build an HTTP client. For OAuth2 connections, use RefreshableClient
|
||||
// so that short-lived tokens are transparently refreshed.
|
||||
httpClient, err := e.connectorHTTPClient(ctx, dbConnector)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create HTTP client for %s connector: %w", dbConnector.Provider, err)
|
||||
}
|
||||
|
||||
// Persist the refreshed token back to the database so subsequent
|
||||
// calls (and other workers) use the updated credentials. Providers
|
||||
// that rotate refresh tokens (HubSpot, DocuSign) will fail on the
|
||||
// next poll if the old refresh token is reused.
|
||||
if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok {
|
||||
if oauth2Conn.AccessToken != tokenBefore {
|
||||
dbConnector.UpdatedAt = time.Now()
|
||||
if err := dbConnector.Update(ctx, conn, e.scope, e.encryptionKey); err != nil {
|
||||
return nil, fmt.Errorf("cannot persist refreshed token for connector %s: %w", *source.ConnectorID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch dbConnector.Provider {
|
||||
case coredata.ConnectorProviderGoogleWorkspace:
|
||||
return drivers.NewGoogleWorkspaceDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderLinear:
|
||||
return drivers.NewLinearDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderSlack:
|
||||
return drivers.NewSlackDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderOnePassword:
|
||||
// Client credentials grant -> Users API driver (to be created in Phase 5).
|
||||
// Authorization code / SCIM grant -> existing SCIM-based driver.
|
||||
if oauth2Conn, ok := dbConnector.Connection.(*connector.OAuth2Connection); ok && oauth2Conn.GrantType == connector.OAuth2GrantTypeClientCredentials {
|
||||
settings, err := dbConnector.OnePasswordUsersAPISettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read 1password users api settings: %w", err)
|
||||
}
|
||||
return drivers.NewOnePasswordUsersAPIDriver(httpClient, settings.AccountID, settings.Region), nil
|
||||
}
|
||||
onePasswordSettings, err := dbConnector.OnePasswordSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read 1password connector settings: %w", err)
|
||||
}
|
||||
if onePasswordSettings.SCIMBridgeURL == "" {
|
||||
return nil, fmt.Errorf("1password connector requires scim_bridge_url in settings")
|
||||
}
|
||||
return drivers.NewOnePasswordDriver(httpClient, onePasswordSettings.SCIMBridgeURL), nil
|
||||
case coredata.ConnectorProviderHubSpot:
|
||||
return drivers.NewHubSpotDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderDocuSign:
|
||||
return drivers.NewDocuSignDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderNotion:
|
||||
return drivers.NewNotionDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderBrex:
|
||||
return drivers.NewBrexDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderTally:
|
||||
tallySettings, err := dbConnector.TallySettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read tally connector settings: %w", err)
|
||||
}
|
||||
if tallySettings.OrganizationID == "" {
|
||||
return nil, fmt.Errorf("tally connector requires organization_id in settings")
|
||||
}
|
||||
return drivers.NewTallyDriver(httpClient, tallySettings.OrganizationID), nil
|
||||
case coredata.ConnectorProviderCloudflare:
|
||||
return drivers.NewCloudflareDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderOpenAI:
|
||||
return drivers.NewOpenAIDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderSentry:
|
||||
sentrySettings, err := dbConnector.SentrySettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read sentry connector settings: %w", err)
|
||||
}
|
||||
// OrganizationSlug may be empty for OAuth connections; the driver auto-discovers it.
|
||||
return drivers.NewSentryDriver(httpClient, sentrySettings.OrganizationSlug), nil
|
||||
case coredata.ConnectorProviderSupabase:
|
||||
supabaseSettings, err := dbConnector.SupabaseSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read supabase connector settings: %w", err)
|
||||
}
|
||||
if supabaseSettings.OrganizationSlug == "" {
|
||||
return nil, fmt.Errorf("supabase connector requires organization_slug in settings")
|
||||
}
|
||||
return drivers.NewSupabaseDriver(httpClient, supabaseSettings.OrganizationSlug), nil
|
||||
case coredata.ConnectorProviderGitHub:
|
||||
githubSettings, err := dbConnector.GitHubSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read github connector settings: %w", err)
|
||||
}
|
||||
if githubSettings.Organization == "" {
|
||||
return nil, fmt.Errorf("github connector requires organization in settings")
|
||||
}
|
||||
return drivers.NewGitHubDriver(httpClient, githubSettings.Organization, e.logger.Named("github")), nil
|
||||
case coredata.ConnectorProviderIntercom:
|
||||
return drivers.NewIntercomDriver(httpClient), nil
|
||||
case coredata.ConnectorProviderResend:
|
||||
return drivers.NewResendDriver(httpClient), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported connector provider %q for access source driver", dbConnector.Provider)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user