diff --git a/pkg/connector/audit_event_feed.go b/pkg/connector/audit_event_feed.go index 9298246e..4f1c0cb8 100644 --- a/pkg/connector/audit_event_feed.go +++ b/pkg/connector/audit_event_feed.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "slices" "sort" "strconv" "strings" @@ -101,15 +102,20 @@ var auditLogActions = map[auditActionKey]auditActionMapping{ grant: &auditGrantMapping{entitlement: groupMemberEntitlement, principalParam: "targetUserId", revoke: true}, }, {auditServiceAccounts, "removeGroup"}: {resourceType: groupResourceType, idParam: "targetGroupId"}, + // Workspace entitlements change through two paths that log different rows. The admin UI's + // access toggles log changeDatabricksWorkspaceAcl / changeDatabricksSqlAcl (mapped below to their + // single role). Workspace SCIM writes log only updateUser/updateGroup, with no ACL row and no + // detail about which entitlement changed, so those actions refresh every workspace entitlement role. + // Keep both mappings: each covers a path the other doesn't. {auditServiceAccounts, "updateGroup"}: { resourceType: groupResourceType, idParam: "targetGroupId", - roleNames: []string{ClusterCreateRole, InstancePoolCreateRole}, + roleNames: entitlements, }, // "add"/"delete" are the real user-lifecycle events; deleteUser is a parameterless PII purge. {auditServiceAccounts, "add"}: {resourceType: userResourceType, idParam: "targetUserId"}, {auditServiceAccounts, "updateUser"}: { resourceType: userResourceType, idParam: "targetUserId", - roleNames: []string{ClusterCreateRole, InstancePoolCreateRole}, + roleNames: entitlements, }, {auditServiceAccounts, "delete"}: {resourceType: userResourceType, idParam: "targetUserId"}, {auditServiceAccounts, "activateUser"}: {resourceType: userResourceType, idParam: "targetUserId"}, @@ -393,8 +399,11 @@ func (f *auditEventFeed) ListEvents( } var events []*v2.Event - var unmappedRows, skippedLookupRows, resourceChangeEvents, grantEvents, revokeEvents int + var unmappedRows, skippedLookupRows, resourceChangeEvents, grantEvents, revokeEvents, duplicateResourceChanges int principalTypes := principalTypeCache{} + // Keep only the last resource change per resource in a page: each one triggers a full refetch + // (costly for workspace roles). Grant and revoke events are never collapsed. + lastResourceChange := map[string]int{} for _, row := range rows { affected, rowRateLimit, err := mapAuditRowToResource(ctx, f.client, principalTypes, row, f.client.GetAccountId(), workspaceLookup) if rowRateLimit != nil { @@ -450,7 +459,14 @@ func (f *auditEventFeed) ListEvents( CreateGrantEvent: &v2.CreateGrantEvent{Entitlement: a.grant.entitlement, Principal: a.grant.principal}, } default: - resourceChangeEvents++ + key := resourceChangeKey(a.resourceId, a.parentResourceId) + if prev, seen := lastResourceChange[key]; seen { + events[prev] = nil + duplicateResourceChanges++ + } else { + resourceChangeEvents++ + } + lastResourceChange[key] = len(events) event.Event = &v2.Event_ResourceChangeEvent{ ResourceChangeEvent: &v2.ResourceChangeEvent{ResourceId: a.resourceId, ParentResourceId: a.parentResourceId}, } @@ -459,6 +475,8 @@ func (f *auditEventFeed) ListEvents( } } + events = slices.DeleteFunc(events, func(e *v2.Event) bool { return e == nil }) + hasMore := rawRowCount >= auditLogPageLimit nextCursor, advanced := advanceEventCursor(cursor, rows, lastRawBoundary, hasMore, lagCutoff) if !advanced { @@ -477,6 +495,7 @@ func (f *auditEventFeed) ListEvents( zap.Int("audit_rows_skipped_lookup_failure", skippedLookupRows), zap.Int("events_total", len(events)), zap.Int("events_resource_change", resourceChangeEvents), + zap.Int("events_resource_change_duplicates_dropped", duplicateResourceChanges), zap.Int("events_create_grant", grantEvents), zap.Int("events_create_revoke", revokeEvents), zap.Bool("has_more", hasMore), @@ -486,6 +505,14 @@ func (f *auditEventFeed) ListEvents( return events, &pagination.StreamState{Cursor: encoded, HasMore: hasMore}, annos, nil } +// resourceChangeKey identifies a resource change by its resource and parent, for dropping +// duplicates within one ListEvents page. +func resourceChangeKey(resourceId, parentResourceId *v2.ResourceId) string { + return fmt.Sprintf("%s\x00%s\x00%s\x00%s", + resourceId.GetResourceType(), resourceId.GetResource(), + parentResourceId.GetResourceType(), parentResourceId.GetResource()) +} + func isRetryableLookupError(err error) bool { switch status.Code(err) { case codes.Unavailable, codes.ResourceExhausted, codes.DeadlineExceeded, codes.Internal, codes.Unknown, codes.Canceled: diff --git a/pkg/connector/audit_event_feed_test.go b/pkg/connector/audit_event_feed_test.go index 60ad8572..7108e7e4 100644 --- a/pkg/connector/audit_event_feed_test.go +++ b/pkg/connector/audit_event_feed_test.go @@ -505,10 +505,52 @@ func TestMapAuditRowToResource(t *testing.T) { }, want: []wantResource{ {userResourceType.Id, "u-1", accountResourceType.Id, accountId}, + {roleResourceType.Id, roleResourceId(WorkspaceAccessRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, + {roleResourceType.Id, roleResourceId(SQLAccessRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, + {roleResourceType.Id, roleResourceId(ClusterCreateRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, + {roleResourceType.Id, roleResourceId(InstancePoolCreateRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, + }, + }, + { + name: "updateGroup in a workspace refreshes the group and every workspace entitlement role", + row: auditLogRow{ + ActionName: "updateGroup", + ServiceName: "accounts", + WorkspaceID: 123, + RequestParams: map[string]string{"targetGroupId": "g-1"}, + }, + want: []wantResource{ + {groupResourceType.Id, groupResourceId(context.Background(), "g-1", accountParent), accountResourceType.Id, accountId}, + {roleResourceType.Id, roleResourceId(WorkspaceAccessRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, + {roleResourceType.Id, roleResourceId(SQLAccessRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, {roleResourceType.Id, roleResourceId(ClusterCreateRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, {roleResourceType.Id, roleResourceId(InstancePoolCreateRole, workspaceParent), workspaceResourceType.Id, "my-workspace"}, }, }, + { + name: "account-level updateUser refreshes only the user", + row: auditLogRow{ + ActionName: "updateUser", + ServiceName: "accounts", + WorkspaceID: 0, + RequestParams: map[string]string{"targetUserId": "u-1"}, + }, + want: []wantResource{ + {userResourceType.Id, "u-1", accountResourceType.Id, accountId}, + }, + }, + { + name: "account-level updateGroup refreshes only the group", + row: auditLogRow{ + ActionName: "updateGroup", + ServiceName: "accounts", + WorkspaceID: 0, + RequestParams: map[string]string{"targetGroupId": "g-1"}, + }, + want: []wantResource{ + {groupResourceType.Id, groupResourceId(context.Background(), "g-1", accountParent), accountResourceType.Id, accountId}, + }, + }, { name: "unknown action is skipped", row: auditLogRow{ActionName: "someUnityCatalogAction"}, @@ -1423,8 +1465,10 @@ func TestListEventsCachesPrincipalTypes(t *testing.T) { if err != nil { t.Fatalf("ListEvents() error = %v", err) } - if len(events) != 5 { - t.Fatalf("len(events) = %d, want 5", len(events)) + // Three sp-1 grants, plus one g-1 change: both gone-1 rows fall back to the same group + // change, and only the last is kept. + if len(events) != 4 { + t.Fatalf("len(events) = %d, want 4", len(events)) } for _, id := range []string{"sp-1", "gone-1"} { for _, kind := range []string{"Users", "Groups", "ServicePrincipals"} { @@ -1465,3 +1509,91 @@ func TestResolvePrincipalTypeDoesNotCacheErrors(t *testing.T) { t.Errorf("lookup made %d calls, want 2 (the error must not be cached)", n) } } + +// TestListEventsDropsDuplicateResourceChanges verifies that when a page names the same resource +// more than once (the same user, or the same workspace role refreshed by several updateUser rows), +// only the last change is kept, with its own row's ID, and grant events are never collapsed. +func TestListEventsDropsDuplicateResourceChanges(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/workspaces") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{{"workspace_id": 1, "workspace_name": "ws1", "deployment_name": "ws1"}}) + return + } + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/sql/warehouses/wh-1") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"wh-1"}`) + return + } + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/2.0/accounts/acct-1/scim/v2/Users/u-1") { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{}`) + return + } + if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/2.0/sql/statements") { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "statement_id": "stmt-1", + "status": map[string]any{"state": "SUCCEEDED"}, + "manifest": map[string]any{ + "schema": map[string]any{ + "columns": []map[string]any{ + {"name": "event_id"}, {"name": "event_time"}, {"name": "workspace_id"}, + {"name": "action_name"}, {"name": "service_name"}, {"name": "request_params"}, + }, + }, + }, + "result": map[string]any{ + "data_array": [][]string{ + {"evt-1", "2026-01-01T00:00:00Z", "1", "updateUser", "accounts", `{"targetUserId":"u-1"}`}, + {"evt-2", "2026-01-01T00:01:00Z", "1", "updateUser", "accounts", `{"targetUserId":"u-2"}`}, + {"evt-3", "2026-01-01T00:02:00Z", "1", "updateUser", "accounts", `{"targetUserId":"u-1"}`}, + {"evt-4", "2026-01-01T00:03:00Z", "0", "addPrincipalToGroup", "accounts", `{"targetGroupId":"g-1","targetUserId":"u-1"}`}, + {"evt-5", "2026-01-01T00:04:00Z", "0", "addPrincipalToGroup", "accounts", `{"targetGroupId":"g-1","targetUserId":"u-1"}`}, + }, + }, + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + t.Fatalf("failed to encode mock statement response: %v", err) + } + })) + defer server.Close() + + target, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + httpClient := &http.Client{Transport: &redirectTransport{target: target}} + client, err := databricks.NewClient(context.Background(), httpClient, "example.cloud.databricks.com", "accounts.cloud.databricks.com", "acct-1", "", &databricks.NoAuth{}, nil) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + feed := newAuditEventFeed(client, nil, "wh-1") + events, _, _, err := feed.ListEvents(context.Background(), testEarliestEvent(), &pagination.StreamToken{Cursor: ""}) + if err != nil { + t.Fatalf("ListEvents() error = %v", err) + } + + // evt-1 is fully superseded: evt-3 names u-1 and the same four roles again. evt-2 keeps only + // u-2, its roles are superseded by evt-3. evt-4 and evt-5: one grant each. + want := []string{"evt-2/0", "evt-3/0", "evt-3/1", "evt-3/2", "evt-3/3", "evt-3/4", "evt-4/0", "evt-5/0"} + got := make([]string, 0, len(events)) + for _, e := range events { + got = append(got, e.GetId()) + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("event IDs = %v, want %v", got, want) + } + for _, e := range events[6:] { + if e.GetCreateGrantEvent() == nil { + t.Errorf("event %s = %+v, want a CreateGrantEvent", e.GetId(), e) + } + } +} diff --git a/test-server/README.md b/test-server/README.md index 4162cd4c..806c978a 100644 --- a/test-server/README.md +++ b/test-server/README.md @@ -237,8 +237,8 @@ account, as in full sync. | 16 | −11h | 0 | workspace / `updatePermissionAssignment` (no `workspace_id`) | principal_id dave | none | | 17 | −10h | ws-beta | workspace / `deletePermissionAssignment` | workspace_id ws-beta | ws-beta changed | | 18 | −9h | ws-gamma | accounts / `add` | targetUserId alice | none: eu-west-1 isn't visible from a us-east-1 warehouse | -| 19 | −8h | ws-alpha | accounts / `updateUser` | targetUserId alice | alice changed; `ws-alpha:allow-cluster-create` and `ws-alpha:allow-instance-pool-create` changed | -| 20 | −7h | 0 | accounts / `updateGroup` | targetGroupId analysts | analysts changed | +| 19 | −8h | ws-alpha | accounts / `updateUser` | targetUserId alice | alice changed; `ws-alpha:workspace-access`, `ws-alpha:databricks-sql-access`, `ws-alpha:allow-cluster-create` and `ws-alpha:allow-instance-pool-create` changed | +| 20 | −7h | ws-beta | accounts / `updateGroup` | targetGroupId analysts | analysts changed; `ws-beta:workspace-access`, `ws-beta:databricks-sql-access`, `ws-beta:allow-cluster-create` and `ws-beta:allow-instance-pool-create` changed | | 21 | −6h | 0 | accounts / `removeGroup` | targetGroupId data-engineers | data-engineers changed | | 22 | −6h (same time as 21) | 0 | accounts / `deactivateUser` | targetUserId dave | dave changed | | 23 | −5h30m | ws-alpha | accounts / `changeDatabricksWorkspaceAcl` | targetUserId carol | ws-alpha changed; `ws-alpha:workspace-access` changed | @@ -246,7 +246,12 @@ account, as in full sync. | 25 | −2h | 0 | accounts / `add` | targetUserId bob | none: newer than the 4-hour lag cutoff | | 26 | −72h | 0 | accounts / `add` | targetUserId carol | none: older than the feed start | -That adds up to 25 events. +The table lists what each row maps to, 31 events in all. Within one poll, `ListEvents` keeps +only the last change for each resource (by resource and parent ID), because a resource change +makes C1 re-read the resource's current state anyway. Grant and revoke events are never +collapsed. Several resources appear more than once (bob in rows 2, 11 and 12, `account_admin` in +10 and 11, the ws-alpha roles in 19 and 23, and others), so the poll emits 21 events, each with +the ID of the last row that named its resource. ## curl examples diff --git a/test-server/seeds.go b/test-server/seeds.go index 5f37ea46..8e35f30c 100644 --- a/test-server/seeds.go +++ b/test-server/seeds.go @@ -128,7 +128,7 @@ func seedAudit(start time.Time) []auditRow { RequestParams: user("scim", userAlice, "alice@example.com")}, {EventID: "e0000001-0000-4000-8000-000000000019", EventTime: at(8, 0), WorkspaceID: wsAlphaID, ServiceName: "accounts", ActionName: "updateUser", RequestParams: user("scim", userAlice, "alice@example.com")}, - {EventID: "e0000001-0000-4000-8000-000000000020", EventTime: at(7, 0), ServiceName: "accounts", ActionName: "updateGroup", + {EventID: "e0000001-0000-4000-8000-000000000020", EventTime: at(7, 0), WorkspaceID: wsBetaID, ServiceName: "accounts", ActionName: "updateGroup", RequestParams: map[string]string{"endpoint": "scim", "targetGroupId": groupAnalysts, "targetGroupName": "analysts"}}, // Two rows with the same event_time, to exercise the (event_time, event_id) ordering. {EventID: "e0000001-0000-4000-8000-000000000021", EventTime: tie, ServiceName: "accounts", ActionName: "removeGroup", diff --git a/test-server/testdata/expected-events.jsonl b/test-server/testdata/expected-events.jsonl index 8a6be962..d81acb14 100644 --- a/test-server/testdata/expected-events.jsonl +++ b/test-server/testdata/expected-events.jsonl @@ -1,24 +1,20 @@ -{"id":"e0000001-0000-4000-8000-000000000001/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000004"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} -{"id":"e0000001-0000-4000-8000-000000000002/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} -{"id":"e0000001-0000-4000-8000-000000000003/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000003"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} -{"id":"e0000001-0000-4000-8000-000000000004/0","resourceChangeEvent":{"resourceId":{"resourceType":"group","resource":"account/test-account/group/5520000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} {"id":"e0000001-0000-4000-8000-000000000005/0","createGrantEvent":{"entitlement":{"resource":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}},"id":"group:account/test-account/group/5520000000000001:member","displayName":"member","purpose":"PURPOSE_VALUE_ASSIGNMENT","slug":"member"},"principal":{"id":{"resourceType":"user","resource":"4410000000000001"}}}} {"id":"e0000001-0000-4000-8000-000000000006/0","createGrantEvent":{"entitlement":{"resource":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}},"id":"group:account/test-account/group/5520000000000001:member","displayName":"member","purpose":"PURPOSE_VALUE_ASSIGNMENT","slug":"member"},"principal":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000002"}}}} {"id":"e0000001-0000-4000-8000-000000000007/0","createGrantEvent":{"entitlement":{"resource":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}},"id":"group:account/test-account/group/5520000000000002:member","displayName":"member","purpose":"PURPOSE_VALUE_ASSIGNMENT","slug":"member"},"principal":{"id":{"resourceType":"service_principal","resource":"6630000000000001"}}}} {"id":"e0000001-0000-4000-8000-000000000008/0","createRevokeEvent":{"entitlement":{"resource":{"id":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}},"id":"group:account/test-account/group/5520000000000001:member","displayName":"member","purpose":"PURPOSE_VALUE_ASSIGNMENT","slug":"member"},"principal":{"id":{"resourceType":"user","resource":"4410000000000002"}}}} -{"id":"e0000001-0000-4000-8000-000000000009/0","resourceChangeEvent":{"resourceId":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} -{"id":"e0000001-0000-4000-8000-000000000010/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} -{"id":"e0000001-0000-4000-8000-000000000010/1","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"account_admin"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} -{"id":"e0000001-0000-4000-8000-000000000011/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} {"id":"e0000001-0000-4000-8000-000000000011/1","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"account_admin"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} {"id":"e0000001-0000-4000-8000-000000000012/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} {"id":"e0000001-0000-4000-8000-000000000014/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000003"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} -{"id":"e0000001-0000-4000-8000-000000000015/0","resourceChangeEvent":{"resourceId":{"resourceType":"workspace","resource":"ws-alpha"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} {"id":"e0000001-0000-4000-8000-000000000017/0","resourceChangeEvent":{"resourceId":{"resourceType":"workspace","resource":"ws-beta"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} {"id":"e0000001-0000-4000-8000-000000000019/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} -{"id":"e0000001-0000-4000-8000-000000000019/1","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-alpha:allow-cluster-create"},"parentResourceId":{"resourceType":"workspace","resource":"ws-alpha"}}} -{"id":"e0000001-0000-4000-8000-000000000019/2","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-alpha:allow-instance-pool-create"},"parentResourceId":{"resourceType":"workspace","resource":"ws-alpha"}}} +{"id":"e0000001-0000-4000-8000-000000000019/2","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-alpha:databricks-sql-access"},"parentResourceId":{"resourceType":"workspace","resource":"ws-alpha"}}} +{"id":"e0000001-0000-4000-8000-000000000019/3","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-alpha:allow-cluster-create"},"parentResourceId":{"resourceType":"workspace","resource":"ws-alpha"}}} +{"id":"e0000001-0000-4000-8000-000000000019/4","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-alpha:allow-instance-pool-create"},"parentResourceId":{"resourceType":"workspace","resource":"ws-alpha"}}} {"id":"e0000001-0000-4000-8000-000000000020/0","resourceChangeEvent":{"resourceId":{"resourceType":"group","resource":"account/test-account/group/5520000000000002"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} +{"id":"e0000001-0000-4000-8000-000000000020/1","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-beta:workspace-access"},"parentResourceId":{"resourceType":"workspace","resource":"ws-beta"}}} +{"id":"e0000001-0000-4000-8000-000000000020/2","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-beta:databricks-sql-access"},"parentResourceId":{"resourceType":"workspace","resource":"ws-beta"}}} +{"id":"e0000001-0000-4000-8000-000000000020/3","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-beta:allow-cluster-create"},"parentResourceId":{"resourceType":"workspace","resource":"ws-beta"}}} +{"id":"e0000001-0000-4000-8000-000000000020/4","resourceChangeEvent":{"resourceId":{"resourceType":"role","resource":"ws-beta:allow-instance-pool-create"},"parentResourceId":{"resourceType":"workspace","resource":"ws-beta"}}} {"id":"e0000001-0000-4000-8000-000000000021/0","resourceChangeEvent":{"resourceId":{"resourceType":"group","resource":"account/test-account/group/5520000000000001"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} {"id":"e0000001-0000-4000-8000-000000000022/0","resourceChangeEvent":{"resourceId":{"resourceType":"user","resource":"4410000000000004"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}} {"id":"e0000001-0000-4000-8000-000000000023/0","resourceChangeEvent":{"resourceId":{"resourceType":"workspace","resource":"ws-alpha"},"parentResourceId":{"resourceType":"account","resource":"test-account"}}}