Skip to content
Merged
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_exists_check` under an entitlement's `revoke`
# makes the connector probe for the principal once the revoke queries have
# committed. 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_exists_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>
)
# Once the revoke queries commit, probe whether the principal still
# exists. No rows means the app deleted the user, so the connector
# reports a ResourceDeleted annotation on the revoke response.
revoke_options:
principal_exists_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"
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ require (
filippo.io/age v1.3.1 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
filippo.io/hpke v0.4.0 // indirect
filippo.io/nistec v0.0.4 // indirect
github.com/DataDog/zstd v1.5.7 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/RaduBerinde/axisds v0.1.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -543,3 +543,7 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
filippo.io/nistec v0.0.4 h1:F14ZHT5htWlMnQVPndX9ro9arf56cBhQxq4LnDI491s=
filippo.io/nistec v0.0.4/go.mod h1:PK/lw8I1gQT4hUML4QGaqljwdDaFcMyFKSXN7kjrtKI=
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 {
// PrincipalExistsCheck probes whether the principal still exists after the revoke queries run.
// No rows means the principal was deleted as a side effect of the revoke.
PrincipalExistsCheck *PrincipalExistsCheck `yaml:"principal_exists_check,omitempty" json:"principal_exists_check,omitempty"`
}

// PrincipalExistsCheck configures a probe query that reports whether the principal still exists after a revoke.
type PrincipalExistsCheck struct {
// Query runs with the same provisioning vars once the revoke queries have committed.
// Returning at least one row means the principal still exists;
// returning no rows means it was deleted as a side effect of the revoke.
// A query that fails does not fail the revoke; the deletion just goes unreported.
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_exists_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.PrincipalExistsCheck)
require.Equal(t,
normalizeQueryString("SELECT 1 FROM Users WHERE Username = ?<username>"),
normalizeQueryString(revoke.RevokeOptions.PrincipalExistsCheck.Query),
)
},
},
}

for _, tt := range tests {
Expand Down
42 changes: 33 additions & 9 deletions pkg/bsql/provisioning.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
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,19 +135,42 @@ func (s *SQLSyncer) Revoke(ctx context.Context, grant *v2.Grant) (annotations.An
}

useTx := !provisioningConfig.Revoke.NoTransaction
err = s.RunProvisioningQueries(ctx, provisioningConfig.Revoke.Queries, provisioningConfig.Revoke.ValidationQueries, provisioningVars, useTx)
if err != nil {
if errors.Is(err, ErrQueryAffectedZeroRows) {
anno := annotations.Annotations{}
anno.Update(&v2.GrantAlreadyRevoked{})
return anno, nil
}

var existsCheck *PrincipalExistsCheck
if provisioningConfig.Revoke.RevokeOptions != nil {
existsCheck = provisioningConfig.Revoke.RevokeOptions.PrincipalExistsCheck
}
principalDeleted, err := s.RunRevokeProvisioning(
ctx,
provisioningConfig.Revoke.Queries,
provisioningConfig.Revoke.ValidationQueries,
existsCheck,
provisioningVars,
useTx,
)
// The exists-check still runs when the revoke queries affected zero rows, so
// principalDeleted is only meaningful on the success and already-revoked paths.
alreadyRevoked := errors.Is(err, ErrQueryAffectedZeroRows)
if err != nil && !alreadyRevoked {
return nil, err
}

anno := annotations.Annotations{}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you need to check the error on line 142 before we use principalDeleted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 fixed in 88e41bc

if principalDeleted {
anno.Append(sdkResource.NewResourceDeleted(grant.GetPrincipal().GetId()))
l.Info(
"revoke deleted principal; reporting ResourceDeleted",
zap.String("grant_id", grant.GetId()),
zap.String("principal_id", grant.GetPrincipal().GetId().GetResource()),
)
}

if alreadyRevoked {
anno.Update(&v2.GrantAlreadyRevoked{})
return anno, nil
}

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