Files
twenty/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts
T
Félix Malfait 116c04d8b2 Record and enforce per-user OAuth application authorizations (#23678)
Sits on `main` now that #23642 has merged. 18 files changed.

## Why

Application tokens are stateless JWTs. When a user completes an OAuth
`authorization_code` exchange, the server issues an access/refresh pair
carrying `userId` as a claim and stores nothing. So today:

- there is no record that a person ever authorized an app, hence nothing
to list on a settings screen
- there is no way for that person to take an app's access away. The
only revocation that exists is uninstalling the app, which is
workspace-wide and admin-only
- `/oauth/revoke` accepted a refresh token, logged it and did nothing,
because there was no state to change

`client_credentials` is unaffected: no user is involved and it returns
an access token with no refresh token.

## What

**`core."applicationAuthorization"`**, one row per (user, application),
unique on that pair so re-authorizing updates in place. Written at the
`authorization_code` exchange, before the token pair is issued, so a
refresh token is never handed out without the grant that makes it
redeemable.

A dedicated table rather than a new `AppTokenType`: this is a grant
keyed on identity, not a token keyed on a secret, and `appToken` is
already overloaded.

FKs to user, workspace, application and userWorkspace all cascade, which
covers hard deletes. Membership removal soft-deletes the `userWorkspace`
row, so that cascade does not fire and the grant outlives the
membership. The refresh path therefore rechecks membership on every
renewal rather than trusting the row's existence.

**Enforcement.** `refresh_token` checks the row when the token carries a
user, and returns `invalid_grant` if it is revoked. Revoking does not
kill live access tokens, so access ends within one access-token window
(`APPLICATION_ACCESS_TOKEN_EXPIRES_IN`, 30 minutes) rather than
instantly. The alternative is a DB read on every API request, which is
not worth it for a 30 minute tail; the UI should say so.

**RFC 7009 revocation now revokes.** Revoking a refresh token revokes
the authorization behind it. It also now checks the token was issued to
the client asking, which it never did before. That check did not matter
while revocation was a no-op; it does now.

**Introspection** reports a refresh token inactive once its
authorization is revoked. Access tokens keep reporting active until they
expire, because they genuinely still work.

**API:** `currentUserApplicationAuthorizations` and
`revokeApplicationAuthorization`, both behind `UserAuthGuard`. The
mutation scopes by `userId` inside the `UPDATE` rather than
read-then-write, so one user cannot revoke another's authorization
by guessing an id.

## Backwards compatibility

Refresh tokens already in the wild have no row. Rejecting them would
sign every live integration out on deploy, so the first refresh
backfills the grant that was always implied. A revoked authorization
keeps its row, so this never resurrects access someone turned off, and
the backfill is insert-only so it cannot overwrite a real consent. If
the user has since left the workspace, the refresh fails instead.

Those tokens carry no scope claim and no record of when consent was
given, so `scopes` and `lastAuthorizedAt` are nullable and left null on
a backfilled row. Null means "the original consent is not on
record" rather than a guess assembled from what the application
declares today; a real re-authorization fills both in. Revoking such a
token lays the row down before marking it, so the revocation sticks
instead of being undone by the next refresh.

## Not in this PR

The settings UI, following how #23643 shipped the sessions API and
#23645 the devices screen.

Introspection still reports a refresh token active once the membership
is gone. That matches access tokens, which genuinely keep working in
that case, so closing it belongs with the wider question of validating
membership on every application-token request.

## Testing

- 29 unit tests across the authorization service and the three OAuth
grant paths
- 9 integration tests on `/oauth/token`, `/oauth/revoke` and the GraphQL
API: scopes as granted are recorded, revoking blocks the next refresh,
re-authorizing reinstates, a pre-record token backfills without
inventing a consent, a revoked pre-record token stays revoked, the
authorization is listed to the user who granted it, revoking from that
list stops the refresh token being redeemed, a repeated revocation
reports no-op, and another user can neither see nor revoke it
- the cross-user isolation and revoke-from-list tests are
mutation-checked: dropping the `userId` scoping from
`revokeAuthorizationById` fails only the isolation test, and disabling
the `revokedAt` check in `oauth.service.ts` fails the revoke-from-list
test plus two pre-existing ones
- full `twenty-server` suite green
- instance command applied against a fresh `database:reset`,
table/index/FK shape verified against `information_schema`

Closes part of https://github.com/twentyhq/core-team-issues/issues/2747

---------

Co-authored-by: prastoin <45004772+prastoin@users.noreply.github.com>
2026-08-03 20:59:28 +02:00

136 lines
4.5 KiB
TypeScript

import { defineRule } from '@oxlint/plugins';
export const RULE_NAME = 'prefer-workspace-scoped-repository';
// Entities that do not fit the scoped wrapper: workspace itself, pivots,
// nullable-workspaceId rows (instance-level config / migrations / tokens),
// and global tables with no workspaceId column at all.
const STRUCTURAL_EXEMPTIONS = new Set<string>([
'WorkspaceEntity',
'UserWorkspaceEntity',
'AppTokenEntity',
'ApplicationRegistrationEntity',
'ApplicationRegistrationClaimEntity',
'ApplicationRegistrationVariableEntity',
// nullable workspaceId — both rows support instance-level and per-workspace use
'KeyValuePairEntity',
'UpgradeMigrationEntity',
// user-scoped auth sessions; workspaceId is null for workspace-agnostic sessions
'UserSessionEntity',
'ApplicationVariableEntity',
'BillingMeterEntity',
'BillingPriceEntity',
'BillingProductEntity',
'BillingSubscriptionItemEntity',
'ConnectedAccountEntity',
'ConnectionProviderEntity',
'FrontComponentEntity',
'LogicFunctionEntity',
'MessageFolderEntity',
'RolePermissionFlagEntity',
'SigningKeyEntity',
'UserEntity',
'WorkspaceSSOIdentityProviderEntity',
]);
// Workspace-scoped entities the wrapper could technically wrap, but where
// the dominant access patterns are cross-workspace (request routing, auth,
// metadata sync, file storage, transaction-bound channel updates) and the
// payoff doesn't justify dual-injecting every call site or growing the
// wrapper API. Treat as a "deliberately not migrated" list, not a backlog.
const WORKSPACE_SCOPED_EXEMPTIONS = new Set<string>([
// Resolved by id alone at auth/request-routing time and inside file-storage
// transactions; very few of the ~50 call sites carry a workspaceId.
'ApplicationEntity',
// Read by user across every workspace they belong to (the "apps you
// authorized" screen) and from the OAuth token endpoint, which has no
// request workspace to scope by.
'ApplicationAuthorizationEntity',
// 20+ call sites across calendar/messaging modules; staged for a dedicated PR.
'CalendarChannelEntity',
'MessageChannelEntity',
// The owning services `extends TypeOrmQueryService<E>` and pass the raw
// repo to `super(...)`; the superclass type doesn't accept the wrapper.
'FieldMetadataEntity',
'ObjectMetadataEntity',
// Only injection lives in a frozen historical upgrade-version-command
// directory that CI's mutation-guard refuses to let us edit.
'DataSourceEntity',
// The domain column is globally unique across workspaces, so duplicate
// preflight checks must query cross-workspace; writes stay on the wrapper.
'EmailingDomainEntity',
]);
// Everything else must use @InjectWorkspaceScopedRepository.
const EXCLUSIONS = new Set<string>([
...STRUCTURAL_EXEMPTIONS,
...WORKSPACE_SCOPED_EXEMPTIONS,
]);
const matchInjectRepositoryEntity = (decorator: any): string | null => {
if (decorator.expression?.type !== 'CallExpression') {
return null;
}
const callee = decorator.expression.callee;
if (callee?.type !== 'Identifier' || callee.name !== 'InjectRepository') {
return null;
}
const [arg] = decorator.expression.arguments;
if (arg?.type !== 'Identifier') {
return null;
}
if (!arg.name.endsWith('Entity')) {
return null;
}
if (EXCLUSIONS.has(arg.name)) {
return null;
}
return arg.name;
};
export const rule = defineRule({
meta: {
type: 'problem',
docs: {
description:
'Disallow raw @InjectRepository for workspace-scoped entities. Use @InjectWorkspaceScopedRepository so workspaceId is enforced on every read/write.',
},
schema: [],
messages: {
preferWorkspaceScopedRepository:
'Use @InjectWorkspaceScopedRepository({{entityName}}) instead of raw @InjectRepository so the workspaceId guard is enforced. If {{entityName}} genuinely does not fit, add it to EXCLUSIONS or suppress with `// eslint-disable-next-line twenty/prefer-workspace-scoped-repository` and a short reason.',
},
},
create: (context) => {
return {
MethodDefinition: (node: any) => {
if (node.kind !== 'constructor') {
return;
}
for (const param of node.value.params ?? []) {
for (const decorator of param.decorators ?? []) {
const entityName = matchInjectRepositoryEntity(decorator);
if (entityName !== null) {
context.report({
node: decorator,
messageId: 'preferWorkspaceScopedRepository',
data: { entityName },
});
}
}
}
},
};
},
});