Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions examples/mssql-revoke-deletes-user.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
---
# Example: reporting a side-effect user deletion from a revoke.
#
# Some applications delete the user record when their last role is revoked.
# When that happens C1 would otherwise not learn the account is gone
# until the next full sync, which blocks an immediate re-grant. Configuring
# `revoke_options.principal_deleted_check` under an entitlement's `revoke`
# makes the connector probe for the principal after the revoke queries run (on
# the same transaction). If the probe returns no rows the connector attaches a
# ResourceDeleted annotation to the revoke response so C1 marks the
# account deleted right away.
app_name: SQL Server Revoke-Deletes-User Example
app_description: Demonstrates revoke_options.principal_deleted_check for apps that delete a user when their last role is revoked

connect:
dsn: "sqlserver://${DB_HOST}:${DB_PORT}?database=${DB_DATABASE}"
user: "${DB_USER}"
password: "${DB_PASSWORD}"

resource_types:
user:
name: "User"
description: "A user within the SQL Server system"
list:
query: |
SELECT
u.UserID AS id,
u.Username AS username,
u.Email AS email
FROM Users u
ORDER BY u.UserID
OFFSET ?<Offset> ROWS FETCH NEXT ?<Limit> ROWS ONLY
pagination:
strategy: "offset"
primary_key: "id"
map:
id: ".username"
display_name: ".username"
description: ".username"
traits:
user:
emails:
- ".email"
status: "enabled"
login: ".username"

# Account provisioning so that a re-grant immediately after deletion
# recreates the user before the new role is assigned.
account_provisioning:
schema:
- name: "username"
description: "The username for the new user"
type: "string"
placeholder: "new_user"
required: true
- name: "email"
description: "Email address for the new user"
type: "string"
placeholder: "user@example.com"
required: true
credentials:
no_password:
preferred: true
validate:
vars:
username: "username"
query: |
SELECT u.UserID AS id, u.Username AS username, u.Email AS email
FROM Users u
WHERE u.Username = ?<username>
create:
vars:
username: "input.username"
email: "input.email"
queries:
- |
IF NOT EXISTS (SELECT 1 FROM Users WHERE Username = ?<username>)
INSERT INTO Users (Username, Email, IsActive, CreatedAt)
VALUES (?<username>, ?<email>, 1, GETDATE())

role:
name: "Role"
description: "A role within the SQL Server system"
list:
query: |
SELECT
RoleID AS id,
RoleName AS role_name,
Description AS description
FROM Roles
ORDER BY RoleID
OFFSET ?<Offset> ROWS FETCH NEXT ?<Limit> ROWS ONLY
pagination:
strategy: "offset"
primary_key: "id"
map:
id: ".role_name"
display_name: ".role_name"
description: ".description"
traits:
role:
profile:
role_name: ".role_name"

static_entitlements:
- id: "member"
display_name: "resource.DisplayName + ' Membership'"
description: "'Member of the ' + resource.DisplayName + ' role'"
purpose: "assignment"
grantable_to:
- "user"
provisioning:
vars:
username: "principal.ID"
role_name: "resource.ID"
grant:
queries:
- |
INSERT INTO UserRoles (UserID, RoleID)
SELECT u.UserID, r.RoleID
FROM Users u, Roles r
WHERE u.Username = ?<username>
AND r.RoleName = ?<role_name>
revoke:
# Revoke removes the membership row, then deletes the user if this
# was their last role. The queries run in a single transaction.
queries:
- |
DELETE ur FROM UserRoles ur
INNER JOIN Users u ON ur.UserID = u.UserID
INNER JOIN Roles r ON ur.RoleID = r.RoleID
WHERE u.Username = ?<username>
AND r.RoleName = ?<role_name>
- |
DELETE FROM Users
WHERE Username = ?<username>
AND NOT EXISTS (
SELECT 1 FROM UserRoles ur
INNER JOIN Users u2 ON ur.UserID = u2.UserID
WHERE u2.Username = ?<username>
)
# After the revoke queries run, probe for the principal. No rows
# means the app deleted the user, so the connector reports a
# ResourceDeleted annotation on the revoke response.
revoke_options:
principal_deleted_check:
query: |
SELECT 1 FROM Users WHERE Username = ?<username>

grants:
- query: |
SELECT
u.Username AS username,
r.RoleName AS role_name
FROM UserRoles ur
INNER JOIN Users u ON ur.UserID = u.UserID
INNER JOIN Roles r ON ur.RoleID = r.RoleID
ORDER BY r.RoleID
OFFSET ?<Offset> ROWS FETCH NEXT ?<Limit> ROWS ONLY
map:
- skip_if: ".role_name != resource.ID"
principal_id: ".username"
principal_type: "user"
entitlement_id: "member"
pagination:
strategy: "offset"
primary_key: "role_name"
26 changes: 25 additions & 1 deletion pkg/bsql/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ type EntitlementProvisioning struct {
Grant *GrantEntitlementProvisioningQueries `yaml:"grant,omitempty" json:"grant,omitempty"`

// Revoke defines the SQL queries and settings for revoking this entitlement.
Revoke *EntitlementProvisioningQueries `yaml:"revoke,omitempty" json:"revoke,omitempty"`
Revoke *RevokeEntitlementProvisioningQueries `yaml:"revoke,omitempty" json:"revoke,omitempty"`

// Vars provides variables that can be used within provisioning SQL queries.
Vars map[string]string `yaml:"vars,omitempty" json:"vars,omitempty"`
Expand All @@ -429,6 +429,22 @@ type EntitlementProvisioningQueries struct {
Queries []string `yaml:"queries,omitempty" json:"queries,omitempty"`
}

// RevokeOptions holds optional revoke-only behavior beyond the shared provisioning queries.
type RevokeOptions struct {
// PrincipalDeletedCheck detects that a revoke deleted the principal itself (e.g. user is deleted when their last role is removed).
PrincipalDeletedCheck *PrincipalDeletedCheck `yaml:"principal_deleted_check,omitempty" json:"principal_deleted_check,omitempty"`
}

// PrincipalDeletedCheck configures a probe query that detects whether the
// principal was deleted as a side effect of a revoke.
type PrincipalDeletedCheck struct {
// Query runs after the revoke queries on the same executor/transaction with
// the same provisioning vars. If it returns no rows, the principal is
// considered deleted and a ResourceDeleted annotation is attached to the
// revoke response. If it returns any row, the principal still exists.
Query string `yaml:"query" json:"query"`
}

type GrantReplaceProvisioningQueries struct {
// Query is the SQL statement used to retrieve grant
Query string `yaml:"query" json:"query"`
Expand All @@ -454,6 +470,14 @@ type GrantEntitlementProvisioningQueries struct {
GrantReplace *GrantReplaceProvisioningQueries `yaml:"grant_replace,omitempty" json:"grant_replace,omitempty"`
}

// RevokeEntitlementProvisioningQueries extends the shared provisioning query fields with revoke-only behavior.
type RevokeEntitlementProvisioningQueries struct {
EntitlementProvisioningQueries `yaml:",inline" json:",inline"`

// RevokeOptions groups optional revoke-only settings such as principal_deleted_check.
RevokeOptions *RevokeOptions `yaml:"revoke_options,omitempty" json:"revoke_options,omitempty"`
}

// GrantsQuery defines the structure for querying existing entitlement grants.
type GrantsQuery struct {
// Vars provides variables that can be used within the grants query.
Expand Down
14 changes: 14 additions & 0 deletions pkg/bsql/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,20 @@ resource_types:
require.Equal(t, "'Grant cancelled by policy.'", grant.RejectIf.Reason)
},
},
{
name: "mssql-revoke-deletes-user-example",
input: loadExampleConfig(t, "mssql-revoke-deletes-user"),
validate: func(t *testing.T, c *Config) {
revoke := c.ResourceTypes["role"].StaticEntitlements[0].Provisioning.Revoke
require.NotNil(t, revoke)
require.NotNil(t, revoke.RevokeOptions)
require.NotNil(t, revoke.RevokeOptions.PrincipalDeletedCheck)
require.Equal(t,
normalizeQueryString("SELECT 1 FROM Users WHERE Username = ?<username>"),
normalizeQueryString(revoke.RevokeOptions.PrincipalDeletedCheck.Query),
)
},
},
}

for _, tt := range tests {
Expand Down
28 changes: 25 additions & 3 deletions pkg/bsql/provisioning.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
"github.com/conductorone/baton-sdk/pkg/crypto"
sdkResource "github.com/conductorone/baton-sdk/pkg/types/resource"
"github.com/conductorone/baton-sql/pkg/helpers"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"
Expand Down Expand Up @@ -134,10 +135,31 @@
}

useTx := !provisioningConfig.Revoke.NoTransaction
err = s.RunProvisioningQueries(ctx, provisioningConfig.Revoke.Queries, provisioningConfig.Revoke.ValidationQueries, provisioningVars, useTx)
var deletedCheck *PrincipalDeletedCheck
if provisioningConfig.Revoke.RevokeOptions != nil {
deletedCheck = provisioningConfig.Revoke.RevokeOptions.PrincipalDeletedCheck
}
principalDeleted, err := s.RunRevokeProvisioning(
ctx,
provisioningConfig.Revoke.Queries,
provisioningConfig.Revoke.ValidationQueries,
deletedCheck,
provisioningVars,
useTx,
)

anno := annotations.Annotations{}
if principalDeleted {
anno.Append(sdkResource.NewResourceDeleted(grant.GetPrincipal().GetId()))

Check failure on line 153 in pkg/bsql/provisioning.go

View workflow job for this annotation

GitHub Actions / test

undefined: sdkResource.NewResourceDeleted
l.Info(
"revoke deleted principal; reporting ResourceDeleted",
zap.String("grant_id", grant.GetId()),
zap.String("principal_id", grant.GetPrincipal().GetId().GetResource()),
)
}

if err != nil {
if errors.Is(err, ErrQueryAffectedZeroRows) {
anno := annotations.Annotations{}
anno.Update(&v2.GrantAlreadyRevoked{})
return anno, nil
}
Expand All @@ -146,7 +168,7 @@
}

l.Debug("revoked grant", zap.String("grant_id", grant.GetId()))
return nil, nil
return anno, nil
}

func (s *SQLSyncer) prepareProvisioningVars(ctx context.Context, vars map[string]string, principal *v2.Resource, entitlement *v2.Entitlement) (map[string]any, error) {
Expand Down
Loading
Loading