Skip to content

Conversation

jordigh
Copy link
Contributor

@jordigh jordigh commented Aug 18, 2025

This adds two new endpoints for enabling or disabling a user.

  • POST /api/users/:userId/disable disables a user
  • POST /api/users/:userId/enable re-enables a user

A disabled user should not be able to log in. If they attempt, they will receive a 403 HTTP forbidden error code.

Attempts to use the API token of a disabled user will also result in 403.

Frontend details (an interface for managing users, displaying information about disabled users) are not in scope here.

A disabled user that is re-enabled is able to come back as if nothing had happened.

Original commit messages:

  • 1d4e2cc User: add a disabledAt property

    This is in analogy with removedAt for documents. If the property is
    not null, then the user is considered disabled. To re-enable a user,
    we null the property again.

  • cc5e614 Authorizer: disallow disabled users from logging in

    A clear 403 is what they get if they try to log in again.

  • c96ba2c HomeDBManager: allow changing the disabledAt property of a user

    Simply saving the new property to the db upon a change.

  • 2fe3648 API: add /api/users/:userId/[enable|disable] endpoints

    These API endpoints allows disabling or enabling a user.

  • 718fc95 HomeDBManaager: remove doc access for disallowed users

    If there is an existing websocket connexion, disallowed users might
    still be able to use it to send data. There is a 5 second cache on the
    doc implementation, but permissions are rechecked after the cache
    expires. Checking if a user is disabled here when getting the document
    should prevent the user from doing any further work on a document.

  • 8fe1476 ApiServer: add tests for new user disable/enable endpoints

    Testing the API endpoints work and also a simple test that a banned
    user can't reach an endpoint via API.

Copy link
Member

@dsagal dsagal left a comment

Choose a reason for hiding this comment

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

I know it's a draft, and I only skimmed at high level. Just wanted to nudge towards referring to this as "disabling users", which is a rare state (like banning or blocking), and for this reason I think it would be clearer if new fields were named "disabled" -- or better (by analogy with removedAt on documents and workspaces), "disabledAt", with a timestamp, rather than "isEnabled". And for method names and API calls, it would also be nice to make it rhyme with other things: like there is a DELETE /api/docs/:docId and soft-delete POST /api/docs/:docId/remove; similarly disabling could be the "soft-delete" analog to deleting a user, with a similarly-rhyming endpoint, and similar name (disableUser, like the "soft" version of deleteUser).

Also I noticed you iterate through connections in DocClients, but that's not all the clients, you'd be missing those who have connected (and authenticated) but haven't opened a document. That's why I suggested using Comm.ts to find all websocket connections for a user.

@jordigh
Copy link
Contributor Author

jordigh commented Aug 19, 2025

I like to reduce the amount of negative properties. It's like keeping track of minus signs in a calculation. "if not is not enabled" adds a bit of cognitive load, at least for me.

This is not about the negative of "enabled", this is about soft-deleting. I find it more cognitively weird to think about an "enabled" user, it's like thinking about an "enabled" file to represent a file that hasn't been moved to trash.

@paulfitz
Copy link
Member

I like to reduce the amount of negative properties. It's like keeping track of minus signs in a calculation. "if not is not enabled" adds a bit of cognitive load, at least for me.

This is not about the negative of "enabled", this is about soft-deleting. I find it more cognitively weird to think about an "enabled" user, it's like thinking about an "enabled" file to represent a file that hasn't been moved to trash.

What about a removed_at column, consistent with soft-deleted docs and workspaces? Then we'd also have a timestamp.

If the choice remains enabled versus disabled, I'd prefer disabled because it is an uncommon state.

@jordigh
Copy link
Contributor Author

jordigh commented Aug 20, 2025

Should removing be undoable? Maybe I'm stuck thinking that removing/unremoving should both be supported. But maybe once a user is removed, there's no need to reinstate them.

@paulfitz
Copy link
Member

Should removing be undoable? Maybe I'm stuck thinking that removing/unremoving should both be supported. But maybe once a user is removed, there's no need to reinstate them.

for docs and workspaces, soft-deletion is undoable (removed_at gets reset to null)

This is in analogy with removedAt for documents. If the property is
not null, then the user is considered disabled. To re-enable a user,
we null the property again.
Simply saving the new property to the db upon a change.
These API endpoints allows disabling or enabling a user.
@jordigh jordigh force-pushed the jordigh/ban-user branch 9 times, most recently from de5e603 to 71bc703 Compare September 3, 2025 01:10
If there is an existing websocket connexion, disallowed users might
still be able to use it to send data. There is a 5 second cache on the
doc implementation, but permissions are rechecked after the cache
expires. Checking if a user is disabled here when getting the document
should prevent the user from doing any further work on a document.
@jordigh jordigh changed the title Backend changes for allowing enabling/disabling a user API for enabling/disabling a user Sep 3, 2025
Testing the API endpoints work and also a simple test that a banned
user can't reach an endpoint via API.
@jordigh jordigh marked this pull request as ready for review September 3, 2025 02:32
Copy link
Collaborator

@fflorent fflorent left a comment

Choose a reason for hiding this comment

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

Few remarks so far, but I haven't reviewed everything yet. The code is pretty clear, sounds promising!

Copy link
Collaborator

@fflorent fflorent left a comment

Choose a reason for hiding this comment

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

The code is pretty clean, thank you very much @jordigh!

My main worry is about the effectiveness of the user deactivation, I see in the tests that you explicitly call flushDocAuthCache(). Would that mean that an attacker could continue working on a document through WebSocket as long as the DocAuth cache exists?

(I hope my question is clear enough, if not, sorry for that, I will double the effort for expressing my point)

Copy link
Collaborator

@fflorent fflorent left a comment

Choose a reason for hiding this comment

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

Thank you @jordigh, that will definitely be a very good addition!

@vviers
Copy link
Collaborator

vviers commented Sep 4, 2025

Looks good to me too and absolutely fits my need! 👌🏼
Thanks @jordigh!

Copy link
Member

@paulfitz paulfitz left a comment

Choose a reason for hiding this comment

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

Looking pretty good, thanks @jordigh, some comments.

return doc;
}

const fullUser = await this._usersManager.getFullUser(userId);
Copy link
Member

Choose a reason for hiding this comment

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

This is an extra database call in a fairly busy spot. And the only thing it needs to get is user.disableAt? For any regular API call, the user wouldn't get anywhere near this code without the user record having been already read by middleware. For the websocket connection, likewise an authorization check would have happened, which would include reading the user record. It does get cached but as you note elsewhere the cache doesn't last long. I'll come back to this when I've read rest of PR to see if there's anything better we can do that you're not already doing.

Copy link
Member

Choose a reason for hiding this comment

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

Okay back. In the "normal" path through this method, there is a query constructed by _doc, with a few extra things we need added on with methods like _addIsSupportWorkspace and _addFeatures. You could pull out whether the user has been deleted into the query result. It is a bit weird since it is unrelated to the document but it would work and save a round trip? There are other paths through this code, it is ok to relax for the lesser traveled paths and make more database round trips.

Another option is to intervene in the part of the code that computes the queries needed to determine whether a user has access to a resource, and make it aware that users can be disabled.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've tried your suggestion to add a join to the query, and it seems to work.


await queryRunner.createIndex("users", new TableIndex({
name: "users__disabled_at",
columnNames: ["disabled_at"],
Copy link
Member

Choose a reason for hiding this comment

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

What is the use case in mind in adding an index for the disabled_at column?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Silly copy-paste without much thought. We never search by disabled_at right now, so there's no need for an index. I've removed it.

ref: chimpyRef,
name: "Chimpy",
picture: null,
disabledAt: null,
Copy link
Member

Choose a reason for hiding this comment

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

In other new optional and nullable fields we've added, we've chosen not to sent them when null. This makes for less noise in diffs.

// Do not include removedAt field if it is not set. It is not relevant to regular
// situations where the user is working with non-deleted resources.
if (key === 'removedAt' && value === null) { return undefined; }
// Don't bother sending option fields if there are no options set.
if (key === 'options' && value === null) { return undefined; }
// Don't prune anything that is explicitly allowed.
if (allowedFields?.has(key)) { return value; }
// User connect id is not used in regular configuration, so we remove it from the response, when
// it's not filled.
if (key === 'connectId' && value === null) { return undefined; }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, good idea, I've done that.

orgAccess?: roles.BasicRole|null;
anonymous?: boolean; // If set to true, the user is the anonymous user.
isMember?: boolean;
disabledAt?: Date|null;
Copy link
Member

Choose a reason for hiding this comment

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

A brief comment would be nice (also in app/common/LoginSessionAPI.ts)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, sure.

private async _changeUserDisabledDate(req: express.Request, disabledAt: Date | null) {
const isAuthorized = await this._gristServer.getInstallAdmin().isAdminReq(req);
if (!isAuthorized) {
throw new ApiError('Only admin users can disable or re-enable users', 401);
Copy link
Member

Choose a reason for hiding this comment

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

I think 403 would the right code here ("not allowed" rather than "not authenticated")

BTW, what do you think of using middleware instead, like:

const requireInstallAdmin = this._gristServer.getInstallAdmin().getMiddlewareRequireAdmin();
this._app.post('/api/users....', requireInstallAdmin, expressWrap(...))

(That's how I see admin-protected endpoints checked in FlexServer and attachEarlyEndpoints.ts)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure. I was doing it by analogy with other 401s in this file, but I guess they should all be 403s too. Would you like that change too for the other 401s, such as /api/profile/isConsultant or api/profile/allowGoogleIn? Those return 401, which come to think of it, is probably also wrong.

The middleware works, but it will give a less specific error message. I guess that's fine?


// If no userId has been found yet, fall back on anonymous.
if (mreq.user?.disabledAt) {
return res.status(403).send('Forbidden');
Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't this prevent access to public URLs? Have you considered instead treating a disabled user as anonymous? I can imagine arguments for this behavior, but then it would be nice to have those spelled out in a comment somewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, all public URLs would be banned too, including public templates or forms.

I've made a small modification here to allow the banned user the opportunity to know that they're logged in as a banned user and they can log out if desired and see public URLs too.

image

I've also added a comment as you suggest to explain this choice.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, ok, but this screenshot is making me worried: it's showing the admin user being disabled... Have you tested this in a server with multiple users?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I have tested it with multiple users.
image

Copy link
Member

@dsagal dsagal left a comment

Choose a reason for hiding this comment

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

Some more thoughts:

  • Could there be a test of behavior for a disabled user who tries to make a call with an API key or with a session cookie -- both to a document, and outside of a document? The tests already here presumably test one of these, but it's hard to tell which, and probably don't test both.
  • I'd be interested a test case of starting a websocket connection, disabling that user, then making an openDoc call. I am worried that the userId is permanently associated with a Client object -- once there, there is only one method that ever checks for it being disabled. Might it be a good idea to destroy Client objects for a user who got disabled? (I think that was part of an earlier version perhaps)

Also, what happens when a disabled user tries to sign in? I don't see a test case for that. If a test is hard to add, have you tested this manually?

Copy link
Member

@dsagal dsagal left a comment

Choose a reason for hiding this comment

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

Thank you! BTW, a reminder about another comment I made in #1775 (review), in case it got lost.

// Otherwise the 403 error page on the client side can't get an
// active user and thinks the user isn't logged in at all, which
// can be more confusing than necessary.
&& !(['/session/access/active', '/session/access/all'].includes(mreq.url))
Copy link
Member

Choose a reason for hiding this comment

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

Is this intentionally allowing all methods, or should it only allow GET? All might make sense, since this may allow a disabled user to switch to another account if they are signed into multiple. Though it's still the same person whom we presumably don't want, so maybe no need to make switching convenient? Anyway, whatever your decision, could you add a comment about allowed methods?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The intent was to only allow GET. I've made that intent explicit now.


// If no userId has been found yet, fall back on anonymous.
if (mreq.user?.disabledAt) {
return res.status(403).send('Forbidden');
Copy link
Member

Choose a reason for hiding this comment

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

Hmm, ok, but this screenshot is making me worried: it's showing the admin user being disabled... Have you tested this in a server with multiple users?

Copy link
Contributor Author

@jordigh jordigh left a comment

Choose a reason for hiding this comment

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

I've added a couple of tests that explicitly test for an API key and for openDoc. I think deleting existing Client instances is ultimately unnecessary because websocket connexions regularly re-check their permissions for each command sent, whenever the 5-second cache expires.

As for logging in, Grist itself doesn't control whether users can log in or not (authentication). All I can handle on Grist's side is what to do with users once they've logged in (authorization). The solution here is that a disabled, logged-in user gets permission denied for almost everything.

// Otherwise the 403 error page on the client side can't get an
// active user and thinks the user isn't logged in at all, which
// can be more confusing than necessary.
&& !(['/session/access/active', '/session/access/all'].includes(mreq.url))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The intent was to only allow GET. I've made that intent explicit now.


// If no userId has been found yet, fall back on anonymous.
if (mreq.user?.disabledAt) {
return res.status(403).send('Forbidden');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, I have tested it with multiple users.
image

@dsagal
Copy link
Member

dsagal commented Sep 25, 2025

I've added a couple of tests that explicitly test for an API key and for openDoc. I think deleting existing Client instances is ultimately unnecessary because websocket connexions regularly re-check their permissions for each command sent, whenever the 5-second cache expires.

Grr, I always have so much trouble with the github review interface. I can't find those new tests you mention.

As for logging in, Grist itself doesn't control whether users can log in or not (authentication). All I can handle on Grist's side is what to do with users once they've logged in (authorization). The solution here is that a disabled, logged-in user gets permission denied for almost everything.

As for logging in, I accept your conclusion, but I disagree that Grist doesn't control authentication. It accepts the sign-in claim when it calls ScopedSession's methods like updateUser or operateOnScopedSession in app/server/lib/GristLoginSystem.ts, core/app/server/lib/GristServer.ts, and core/app/server/lib/SamlConfig.ts. None of those places currently validate it against the database, which is why it's awkward to reject at those points. But it's definitely up to Grist if we wanted to be stricter.

}
}

// In order to let a disabled user know that they're logged in and
Copy link
Member

Choose a reason for hiding this comment

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

How about moving this under if (disabledAt), since in nearly all cases this check doesn't need to happen?

assert.deepEqual(resp.data.map((org: any) => org.name),
['Kiwiland', 'Fish', 'Flightless', 'Primately']);

// Check that a disabled user cannot use an api key
Copy link
Member

Choose a reason for hiding this comment

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

Two notes about these checks.

For one, they check requests to /api/orgs endpoint using API key. This would be better moved to it("prevents disabled users from logging in") -- which checks requests to other endpoints using API key. This particular test case is testing something about setting a new API key, and it's confusing to add unrelated checks here.

For another, it would be good to have a test case somewhere that uses a session cookie instead of the API key. For that you can use server.getCookieLogin(), and that check should also be in a test case specific to disabling users (could be the same it("prevents disabled users from logging in") one).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, I've modified that test to include both token and cookie auth.

I need to write a test that does actually use sessions, and this
environment variable is getting in the way. Let's move it out of the
way.
A clear 403 is what they get if they try to log in again.
Copy link
Member

@dsagal dsagal left a comment

Choose a reason for hiding this comment

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

Thank you. Looks good to me! (Reminder that this PR should still wait for Paul's review too.)

async function getChimpyCookie() {
const session = new TestSession(server);
return {
... await session.getCookieLogin(
Copy link
Member

Choose a reason for hiding this comment

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

Is wrapping into {... X} needed here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry, vestige of a different approach. Gone now.

updateUserLocale(locale: string|null): Promise<void>;
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
disableUser(userId: number): Promise<void>;
enableUser(userId: number): Promise<void>
Copy link
Member

Choose a reason for hiding this comment

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

(missing semicolon?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Huh, how did that get past the linter?

Fixed.

const mreq = req as RequestWithLogin;
const userId = mreq.userId;
const targetUserId = integerParam(req.params.userId, 'userId');
if (targetUserId == userId) {
Copy link
Member

Choose a reason for hiding this comment

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

Please avoid ever using ==, favor ===.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, don't know how that one slipped through.

);

if (!isSessionGetRequest) {
throw new ApiError('Forbidden', 403);
Copy link
Member

Choose a reason for hiding this comment

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

I think it would be ok to give a more explanatory error message. While other social products may attempt to not let a banned user know they are banned, I haven't seen a strong argument for Grist doing the same, and without that argument the general benefits of clarity win.

assert.equal((await cliOwner.send("fetchTable", 0, 'Data')).data.tableData[3].A, 42);
assert.equal((await cliEditor.send("fetchTable", 0, 'Data')).data.tableData[3].A, 42);

// Large ham is the admin
Copy link
Member

Choose a reason for hiding this comment

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

Add potted explanation of "large ham" or just say "ham"

Copy link
Contributor Author

@jordigh jordigh left a comment

Choose a reason for hiding this comment

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

@paulfitz made the requested changes

updateUserLocale(locale: string|null): Promise<void>;
updateAllowGoogleLogin(allowGoogleLogin: boolean): Promise<void>;
disableUser(userId: number): Promise<void>;
enableUser(userId: number): Promise<void>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Huh, how did that get past the linter?

Fixed.

const mreq = req as RequestWithLogin;
const userId = mreq.userId;
const targetUserId = integerParam(req.params.userId, 'userId');
if (targetUserId == userId) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, don't know how that one slipped through.

Copy link
Member

@paulfitz paulfitz left a comment

Choose a reason for hiding this comment

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

Thanks @jordigh !

@jordigh jordigh merged commit 7b6b152 into main Oct 1, 2025
15 checks passed
@jordigh jordigh deleted the jordigh/ban-user branch October 1, 2025 19:52
@hexaltation hexaltation mentioned this pull request Oct 7, 2025
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants