116c04d8b2
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>
136 lines
4.5 KiB
TypeScript
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 },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
});
|