Skip to content

Instantly share code, notes, and snippets.

@fflorent
Last active April 4, 2025 15:14
Show Gist options
  • Save fflorent/dd11a15e424ab4828f679ab038eea764 to your computer and use it in GitHub Desktop.
Save fflorent/dd11a15e424ab4828f679ab038eea764 to your computer and use it in GitHub Desktop.

Test the SCIM /Groups endpoint on Grist

This document explains how to test the SCIM /Groups and /Roles endpoints as proposed in this PR: gristlabs/grist-core#1357

Fetch grist-omnibus

To help having a setup quickly with multiple accounts, I suggest to use grist-omnibus. I have made a fork (source) so the image is based on my PR and it provides accounts for testing.

First fetch and run the grist-omnibus image with the following environment arguments:

docker run --name scim-group --rm -v /tmp/grist:/persist -p 8484:80 \
  -e URL=http://localhost:8484 -e GRIST_SCIM_USER="[email protected]" \
  -e GRIST_ENABLE_SCIM=1 -e [email protected] -e TEAM=cool-bean \
  fflorent/grist-omnibus:scim-group

Then you may go to this link: http://localhost:8484

To login, you are provided these 3 test accounts:

Create an API key

In order to access the Grist API endpoint, you must create an API key for the user [email protected] as described in this document: https://support.getgrist.com/rest-api/#authentication

Then store this API key as the BEARER variable in your bash environment:

BEARER="<your api key here>"

Logging in with the accounts

You are invited to log in using the testing accounts provided. Please note that it is expected that admin2 and admin3 don't have access to the default team site. So after logging in, they are displayed an error of this sort: image

Roles and Groups

Before starting, a quick overview of the changes brought by the PR. The details are provided in the PR description:

  • The /Groups endpoint is provided to represent a group of users (aka teams);
  • The /Roles endpoint to allow giving access to Users and Groups to a certain resource (team sites, workspaces, and doucuments). This endpoint is actually an extension I made, which is allowed by the RFC;

The API is not documented yet. Note that they are identical to the /Users API, which is documented here: https://support.getgrist.com/api/#tag/scim

And important note regarding the /Roles endpoints:

  • creation and deletion of Roles are not supported, they are tight to their associated resource and may be created / destroyed with them;
  • We only allow to update their memberships, in other word, to add a Role to Users and Groups through their endpoints;
  • the other endpoints (GET /Roles, GET /Roles/{id} and POST /Roles/.search) are supported, they work similarly to the other SCIM endpoints;

Playing with the API

Create a Group

Let's create a Group of name test-group:

curl -X POST -H "Authorization: Bearer $BEARER" -v http://localhost:8484/api/scim/v2/Groups \
  -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "test-group"}' \
  -H 'Content-Type: application/json'

The group should exist now, let's fetch all the groups:

$ curl -X GET -H "Authorization: Bearer $BEARER" -v http://localhost:8484/api/scim/v2/Groups | jq
Note: Unnecessary use of -X or --request, GET is already inferred.
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0*   Trying 127.0.0.1:8484...
* Connected to localhost (127.0.0.1) port 8484 (#0)
> GET /api/scim/v2/Groups HTTP/1.1
> Host: localhost:8484
> User-Agent: curl/7.88.1
> Accept: */*
> Authorization: Bearer dac808d7f6da9309a7dc8c2e07607989bb38ee8f
> 
< HTTP/1.1 200 OK
< Access-Control-Allow-Credentials: true
< Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With
< Access-Control-Allow-Methods: GET, PATCH, PUT, POST, DELETE, OPTIONS
< Cache-Control: no-cache
< Content-Language: en
< Content-Length: 308
< Content-Type: application/scim+json; charset=utf-8
< Date: Sun, 02 Feb 2025 19:28:50 GMT
< Etag: W/"134-9YHU63lYRStGHIAK4zehDL+rD90"
< Set-Cookie: grist_core=s%3Ag-f564ce4e93a7325b0c47739bd1faf59d455ca905a59cc14d5b1978cd8569e460.jckAH2bl7hjzSlytWxPpfy8H2NmeC9STHfidJdDpVv0; Path=/; HttpOnly; SameSite=Lax
< X-Powered-By: Express
< 
{ [308 bytes data]
100   308  100   308    0     0  11847      0 --:--:-- --:--:-- --:--:-- 12320
* Connection #0 to host localhost left intact
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "totalResults": 1,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:Group"
      ],
      "id": "29",
      "meta": {
        "resourceType": "Group",
        "location": "/api/scim/v2/Groups/29"
      },
      "displayName": "test-group",
      "members": []
    }
  ],
  "startIndex": 1,
  "itemsPerPage": 20
}

In our example, the ID of the Group is 29. As we would like to add [email protected], we have to search for this member using the SCIM /Users endpoint (⚠️ Please ensure you have logged in at least once using this email beforehand):

$ curl -H "Authorization: Bearer $BEARER" \
  'http://localhost:8484/api/scim/v2/Users?filter=userName%20eq%20%[email protected]%22' | jq
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "totalResults": 1,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:User"
      ],
      "id": "6",
      "meta": {
        "resourceType": "User",
        "location": "/api/scim/v2/Users/6"
      },
      "userName": "[email protected]",
      "name": {
        "formatted": "admin2"
      },
      "displayName": "admin2",
      "preferredLanguage": "en",
      "locale": "en",
      "emails": [
        {
          "value": "[email protected]",
          "primary": true
        }
      ]
    }
  ],
  "startIndex": 1,
  "itemsPerPage": 20
}

➡️ admin2's ID is 6 in our example.

Now let's update the Group to add this user using the PUT verb (alternatively, you may use PATCH as specified in the RFC):

GROUP_ID=29
ADMIN2_ID=6
curl -X PUT -H "Authorization: Bearer $BEARER" -v http://localhost:8484/api/scim/v2/Groups/$GROUP_ID \
  -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "test-group", "members": [{"value": "'$ADMIN2_ID'", "type": "User"}]}' \
  -H 'Content-Type: application/json'

Let's inspect the group now:

$ curl -H "Authorization: Bearer $BEARER" \
  'http://localhost:8484/api/scim/v2/Groups/'$GROUP_ID | jq
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:Group"
  ],
  "id": "29",
  "meta": {
    "resourceType": "Group",
    "location": "/api/scim/v2/Groups/29"
  },
  "displayName": "test-group",
  "members": [
    {
      "value": "6",
      "display": "admin2",
      "$ref": "/api/scim/v2/Users/6",
      "type": "User"
    }
  ]
}

(You may repeat the operation to add admin3 if you would like)

🎉 Great, at this point, we've got a Group, but no access have been granted to admin2. Let's take a look at the Roles:

$ curl -X GET -H "Authorization: Bearer $BEARER" -v http://localhost:8484/api/scim/v2/Roles | jq
Note: Unnecessary use of -X or --request, GET is already inferred.
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0*   Trying 127.0.0.1:8484...
* Connected to localhost (127.0.0.1) port 8484 (#0)
> GET /api/scim/v2/Roles HTTP/1.1
> Host: localhost:8484
> User-Agent: curl/7.88.1
> Accept: */*
> Authorization: Bearer dac808d7f6da9309a7dc8c2e07607989bb38ee8f
> 
< HTTP/1.1 200 OK
< Access-Control-Allow-Credentials: true
< Access-Control-Allow-Headers: Authorization, Content-Type, X-Requested-With
< Access-Control-Allow-Methods: GET, PATCH, PUT, POST, DELETE, OPTIONS
< Cache-Control: no-cache
< Content-Language: en
< Content-Length: 4541
< Content-Type: application/scim+json; charset=utf-8
< Date: Sun, 02 Feb 2025 19:53:32 GMT
< Etag: W/"11bd-S0BFHE7OB+VkPO47yzTWn6rkN6Y"
< Set-Cookie: grist_core=s%3Ag-3d7a957a657a01a01f3f21e3acfb4df2767e72263bce9c6d0f9bd406fa377c76.6GG35jhUr9wwKz%2BBikLt3y3cy2A4QqAXJC%2FFl9DZF1E; Path=/; HttpOnly; SameSite=Lax
< X-Powered-By: Express
< 
{ [3490 bytes data]
100  4541  100  4541    0     0  88037      0 --:--:-- --:--:-- --:--:-- 89039
* Connection #0 to host localhost left intact
{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "totalResults": 45,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:Grist:1.0:Role"
      ],
      "id": "1",
      "meta": {
        "resourceType": "Role",
        "location": "/api/scim/v2/Roles/1"
      },
      "displayName": "owners",
      "members": [
        {
          "value": "4",
          "display": "Support",
          "$ref": "/api/scim/v2/Users/4",
          "type": "User"
        }
      ],
      "orgId": 1
    },
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:Grist:1.0:Role"
      ],
      "id": "2",
      "meta": {
        "resourceType": "Role",
        "location": "/api/scim/v2/Roles/2"
      },
      "displayName": "editors",
      "members": [],
      "orgId": 1
    },
    ....

There are a lot of them, that's not very helpful. We should instead use a filter. Let's retrieve first the cool-bean team site using the Grist API:

curl -X GET -H "Authorization: Bearer $BEARER" http://localhost:8484/api/orgs | jq
[
  {
    "name": "Personal",
    "createdAt": "2025-02-02T09:31:10.000Z",
    "updatedAt": "2025-02-02T09:31:10.651Z",
    "id": 2,
    "domain": "docs-5",
    "host": null,
    "owner": {
      "id": 5,
      "name": "[email protected]",
      "picture": null,
      "ref": "2NmoT3Kq7fZ36PfQ4TCL8K"
    },
    "access": "owners"
  },
  {
    "name": "cool-bean",
    "createdAt": "2025-02-02T09:31:10.000Z",
    "updatedAt": "2025-02-02T09:31:10.667Z",
    "id": 3,
    "domain": "cool-bean",
    "host": null,
    "owner": null,
    "access": "owners"
  }
]

➡️ The cool-bean team site (aka organization) has an ID of 3 in our example.

Let's find the Roles for this team site:

$ ORG_ID=3
$ curl -H "Authorization: Bearer $BEARER" -X POST http://localhost:8484/api/scim/v2/Roles/.search \
  --header 'Content-Type: application/json' -d \
  '{"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], "filter": "orgId eq '$ORG_ID'"}' | jq

{
  "schemas": [
    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
  ],
  "totalResults": 5,
  "Resources": [
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:Grist:1.0:Role"
      ],
      "id": "19",
      "meta": {
        "resourceType": "Role",
        "location": "/api/scim/v2/Roles/19"
      },
      "displayName": "owners",
      "members": [
        {
          "value": "5",
          "display": "[email protected]",
          "$ref": "/api/scim/v2/Users/5",
          "type": "User"
        }
      ],
      "orgId": 3
    },
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:Grist:1.0:Role"
      ],
      "id": "20",
      "meta": {
        "resourceType": "Role",
        "location": "/api/scim/v2/Roles/20"
      },
      "displayName": "editors",
      "members": [],
      "orgId": 3
    },
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:Grist:1.0:Role"
      ],
      "id": "21",
      "meta": {
        "resourceType": "Role",
        "location": "/api/scim/v2/Roles/21"
      },
      "displayName": "viewers",
      "members": [],
      "orgId": 3
    },
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:Grist:1.0:Role"
      ],
      "id": "22",
      "meta": {
        "resourceType": "Role",
        "location": "/api/scim/v2/Roles/22"
      },
      "displayName": "guests",
      "members": [],
      "orgId": 3
    },
    {
      "schemas": [
        "urn:ietf:params:scim:schemas:Grist:1.0:Role"
      ],
      "id": "23",
      "meta": {
        "resourceType": "Role",
        "location": "/api/scim/v2/Roles/23"
      },
      "displayName": "members",
      "members": [],
      "orgId": 3
    }
  ],
  "startIndex": 1,
  "itemsPerPage": 20
}

OK, let's give our group the editor Role (id=20) using PATCH:

$ ROLE_ID=20
$ curl -H "Authorization: Bearer $BEARER" -X PATCH "http://localhost:8484/api/scim/v2/Roles/$ROLE_ID" \
  --header 'Content-Type: application/json' -d \
  '{"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{"op": "add", "path": "members", "value": [{"value": "'$GROUP_ID'", "type": "Group"}]}]}'

Now, you should see the group added to the given Role, so its users should have now access to the cool-bean org as editors:

$ curl -H "Authorization: Bearer $BEARER" -X GET "http://localhost:8484/api/scim/v2/Roles/$ROLE_ID" --header 'Content-Type: application/json' | jq
{
  "schemas": [
    "urn:ietf:params:scim:schemas:Grist:1.0:Role"
  ],
  "id": "20",
  "meta": {
    "resourceType": "Role",
    "location": "/api/scim/v2/Roles/20"
  },
  "displayName": "editors",
  "members": [
    {
      "value": "29",
      "display": "test-group",
      "$ref": "/api/scim/v2/Groups/29",
      "type": "Group"
    }
  ],
  "orgId": 3
}

🥳 If you log in using admin2, you should see the cool-bean team site now!

Now let's say our group has no reasons to exist anymore, let's delete it:

$ curl -H "Authorization: Bearer $BEARER" -X DELETE "http://localhost:8484/api/scim/v2/Groups/$GROUP_ID"

Now admin2 should normally have lost access to cool-bean.

Conclusion

With this showcase, we have been able to:

  1. List groups, optionally with filters (GET /Groups)
  2. List a single group (GET /Groups/{id})
  3. Create a group (POST /Groups)
  4. Update an existing group to add a user member (PUT /Groups/{id} or PATCH /Groups/{id})
  5. Delete a group
  6. List Roles, optionally with filters (GET /Roles)
  7. List a certain Role (GET /Roles/{id})
  8. Update a Role to add members, so a Group is granted a certain permission to an organization (PUT /Roles/{id} or PATCH /Roles/{id})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment