diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 1d2f7b5291..262f6b5ac9 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -2510,6 +2510,18 @@ type UsageAnalytics { userDailyUsage: UsageUserDaily } +type ApplicationAuthorization { + id: UUID! + applicationId: UUID! + workspaceId: UUID! + applicationName: String! + applicationUniversalIdentifier: String + scopes: [String!] + lastAuthorizedAt: DateTime + lastUsedAt: DateTime! + createdAt: DateTime! +} + type DevelopmentApplication { id: String! universalIdentifier: String! @@ -3306,6 +3318,7 @@ type Query { getAddressDetails(placeId: String!, token: String!): PlaceDetailsResult! getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics! findManyPublicDomains: [PublicDomain!]! + currentUserApplicationAuthorizations: [ApplicationAuthorization!]! } input GetApiKeyInput { @@ -3651,6 +3664,7 @@ type Mutation { createDevelopmentApplication(universalIdentifier: String!, name: String!): DevelopmentApplication! syncApplication(manifest: JSON!, dryRun: Boolean): WorkspaceMigration! uploadApplicationFile(file: Upload!, applicationUniversalIdentifier: String!, fileFolder: FileFolder!, filePath: String!): File! + revokeApplicationAuthorization(applicationAuthorizationId: UUID!): Boolean! generateApplicationToken(applicationId: UUID!): ApplicationTokenPair! renewApplicationToken(applicationRefreshToken: String!): ApplicationTokenPair! } diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 3f35c151a7..24e95f96e8 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2198,6 +2198,19 @@ export interface UsageAnalytics { __typename: 'UsageAnalytics' } +export interface ApplicationAuthorization { + id: Scalars['UUID'] + applicationId: Scalars['UUID'] + workspaceId: Scalars['UUID'] + applicationName: Scalars['String'] + applicationUniversalIdentifier?: Scalars['String'] + scopes?: Scalars['String'][] + lastAuthorizedAt?: Scalars['DateTime'] + lastUsedAt: Scalars['DateTime'] + createdAt: Scalars['DateTime'] + __typename: 'ApplicationAuthorization' +} + export interface DevelopmentApplication { id: Scalars['String'] universalIdentifier: Scalars['String'] @@ -2922,6 +2935,7 @@ export interface Query { getAddressDetails: PlaceDetailsResult getUsageAnalytics: UsageAnalytics findManyPublicDomains: PublicDomain[] + currentUserApplicationAuthorizations: ApplicationAuthorization[] __typename: 'Query' } @@ -3167,6 +3181,7 @@ export interface Mutation { createDevelopmentApplication: DevelopmentApplication syncApplication: WorkspaceMigration uploadApplicationFile: File + revokeApplicationAuthorization: Scalars['Boolean'] generateApplicationToken: ApplicationTokenPair renewApplicationToken: ApplicationTokenPair __typename: 'Mutation' @@ -5488,6 +5503,20 @@ export interface UsageAnalyticsGenqlSelection{ __scalar?: boolean | number } +export interface ApplicationAuthorizationGenqlSelection{ + id?: boolean | number + applicationId?: boolean | number + workspaceId?: boolean | number + applicationName?: boolean | number + applicationUniversalIdentifier?: boolean | number + scopes?: boolean | number + lastAuthorizedAt?: boolean | number + lastUsedAt?: boolean | number + createdAt?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + export interface DevelopmentApplicationGenqlSelection{ id?: boolean | number universalIdentifier?: boolean | number @@ -6246,6 +6275,7 @@ export interface QueryGenqlSelection{ getAddressDetails?: (PlaceDetailsResultGenqlSelection & { __args: {placeId: Scalars['String'], token: Scalars['String']} }) getUsageAnalytics?: (UsageAnalyticsGenqlSelection & { __args?: {input?: (UsageAnalyticsInput | null)} }) findManyPublicDomains?: PublicDomainGenqlSelection + currentUserApplicationAuthorizations?: ApplicationAuthorizationGenqlSelection __typename?: boolean | number __scalar?: boolean | number } @@ -6520,6 +6550,7 @@ export interface MutationGenqlSelection{ createDevelopmentApplication?: (DevelopmentApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], name: Scalars['String']} }) syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON'], dryRun?: (Scalars['Boolean'] | null)} }) uploadApplicationFile?: (FileGenqlSelection & { __args: {file: Scalars['Upload'], applicationUniversalIdentifier: Scalars['String'], fileFolder: FileFolder, filePath: Scalars['String']} }) + revokeApplicationAuthorization?: { __args: {applicationAuthorizationId: Scalars['UUID']} } generateApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationId: Scalars['UUID']} }) renewApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationRefreshToken: Scalars['String']} }) __typename?: boolean | number @@ -8562,6 +8593,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const ApplicationAuthorization_possibleTypes: string[] = ['ApplicationAuthorization'] + export const isApplicationAuthorization = (obj?: { __typename?: any } | null): obj is ApplicationAuthorization => { + if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationAuthorization"') + return ApplicationAuthorization_possibleTypes.includes(obj.__typename) + } + + + const DevelopmentApplication_possibleTypes: string[] = ['DevelopmentApplication'] export const isDevelopmentApplication = (obj?: { __typename?: any } | null): obj is DevelopmentApplication => { if (!obj?.__typename) throw new Error('__typename is missing in "isDevelopmentApplication"') diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 06c3b6d3f7..f0daefdf58 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -66,30 +66,30 @@ export default { 226, 239, 245, - 280, - 282, + 281, 283, 284, 285, 286, 287, 288, - 295, + 289, 296, - 299, - 336, - 342, - 344, + 297, + 300, + 337, + 343, 345, 346, 347, - 349, - 351, - 364, - 371, - 378, + 348, + 350, + 352, + 365, + 372, 379, - 511 + 380, + 512 ], "types": { "BillingProductDTO": { @@ -4971,6 +4971,38 @@ export default { 1 ] }, + "ApplicationAuthorization": { + "id": [ + 4 + ], + "applicationId": [ + 4 + ], + "workspaceId": [ + 4 + ], + "applicationName": [ + 1 + ], + "applicationUniversalIdentifier": [ + 1 + ], + "scopes": [ + 1 + ], + "lastAuthorizedAt": [ + 6 + ], + "lastUsedAt": [ + 6 + ], + "createdAt": [ + 6 + ], + "__typename": [ + 1 + ] + }, "DevelopmentApplication": { "id": [ 1 @@ -5064,10 +5096,10 @@ export default { 1 ], "status": [ - 280 + 281 ], "verificationRecords": [ - 278 + 279 ], "verifiedAt": [ 6 @@ -5082,7 +5114,7 @@ export default { 4 ], "visibility": [ - 282 + 283 ], "handle": [ 1 @@ -5091,16 +5123,16 @@ export default { 1 ], "type": [ - 283 + 284 ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 284 + 285 ], "messageFolderImportPolicy": [ - 285 + 286 ], "excludeNonProfessionalEmails": [ 3 @@ -5109,7 +5141,7 @@ export default { 3 ], "pendingGroupEmailsAction": [ - 286 + 287 ], "isSyncEnabled": [ 3 @@ -5118,10 +5150,10 @@ export default { 6 ], "syncStatus": [ - 287 + 288 ], "syncStage": [ - 288 + 289 ], "syncStageStartedAt": [ 6 @@ -5157,7 +5189,7 @@ export default { "MessageChannelSyncStage": {}, "CreateEmailGroupChannelOutput": { "messageChannel": [ - 281 + 282 ], "forwardingAddress": [ 1 @@ -5219,7 +5251,7 @@ export default { 31 ], "skipped": [ - 292 + 293 ], "__typename": [ 1 @@ -5236,10 +5268,10 @@ export default { 1 ], "reason": [ - 295 + 296 ], "source": [ - 296 + 297 ], "unsubscribeTopicId": [ 4 @@ -5252,7 +5284,7 @@ export default { "MessageSuppressionSource": {}, "MessageSuppressionList": { "records": [ - 294 + 295 ], "totalCount": [ 31 @@ -5278,7 +5310,7 @@ export default { 1 ], "visibility": [ - 299 + 300 ], "__typename": [ 1 @@ -5324,7 +5356,7 @@ export default { 1 ], "location": [ - 301 + 302 ], "__typename": [ 1 @@ -5352,13 +5384,13 @@ export default { 1 ], "IMAP": [ - 303 + 304 ], "SMTP": [ - 303 + 304 ], "CALDAV": [ - 303 + 304 ], "__typename": [ 1 @@ -5378,7 +5410,7 @@ export default { 4 ], "connectionParameters": [ - 304 + 305 ], "__typename": [ 1 @@ -5600,7 +5632,7 @@ export default { 1 ], "series": [ - 313 + 314 ], "xAxisLabel": [ 1 @@ -5649,7 +5681,7 @@ export default { 1 ], "data": [ - 315 + 316 ], "__typename": [ 1 @@ -5657,7 +5689,7 @@ export default { }, "LineChartData": { "series": [ - 316 + 317 ], "xAxisLabel": [ 1 @@ -5694,7 +5726,7 @@ export default { }, "PieChartData": { "data": [ - 318 + 319 ], "showLegend": [ 3 @@ -5799,13 +5831,13 @@ export default { }, "EventLogQueryResult": { "records": [ - 323 + 324 ], "totalCount": [ 31 ], "pageInfo": [ - 324 + 325 ], "__typename": [ 1 @@ -5869,7 +5901,7 @@ export default { 1 ], "parts": [ - 309 + 310 ], "processedAt": [ 6 @@ -5938,7 +5970,7 @@ export default { }, "AiSystemPromptPreview": { "sections": [ - 329 + 330 ], "estimatedTokenCount": [ 31 @@ -5966,7 +5998,7 @@ export default { 31 ], "error": [ - 331 + 332 ], "__typename": [ 1 @@ -5999,10 +6031,10 @@ export default { }, "StartWorkspaceSetupChatResult": { "outcome": [ - 336 + 337 ], "thread": [ - 328 + 329 ], "__typename": [ 1 @@ -6040,10 +6072,10 @@ export default { 4 ], "evaluations": [ - 337 + 338 ], "messages": [ - 327 + 328 ], "createdAt": [ 6 @@ -6085,7 +6117,7 @@ export default { 5 ], "scope": [ - 342 + 343 ], "__typename": [ 1 @@ -6100,19 +6132,19 @@ export default { 1 ], "syncStatus": [ - 344 - ], - "syncStage": [ 345 ], - "visibility": [ + "syncStage": [ 346 ], + "visibility": [ + 347 + ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 347 + 348 ], "isSyncEnabled": [ 3 @@ -6163,7 +6195,7 @@ export default { 1 ], "pendingSyncAction": [ - 349 + 350 ], "messageChannelId": [ 4 @@ -6181,7 +6213,7 @@ export default { "MessageFolderPendingSyncAction": {}, "CollectionHash": { "collectionName": [ - 351 + 352 ], "hash": [ 1 @@ -6245,13 +6277,13 @@ export default { }, "MinimalMetadata": { "objectMetadataItems": [ - 352 - ], - "views": [ 353 ], + "views": [ + 354 + ], "collectionHashes": [ - 350 + 351 ], "__typename": [ 1 @@ -6425,7 +6457,7 @@ export default { 7, { "input": [ - 356, + 357, "GetApiKeyInput!" ] } @@ -6531,7 +6563,7 @@ export default { 11, { "input": [ - 357, + 358, "AgentIdInput!" ] } @@ -6565,7 +6597,7 @@ export default { "CursorPaging!" ], "filter": [ - 358, + 359, "ObjectFilter!" ] } @@ -6574,7 +6606,7 @@ export default { 22, { "input": [ - 359, + 360, "LogicFunctionIdInput!" ] } @@ -6586,7 +6618,7 @@ export default { 5, { "input": [ - 359, + 360, "LogicFunctionIdInput!" ] } @@ -6595,7 +6627,7 @@ export default { 1, { "input": [ - 359, + 360, "LogicFunctionIdInput!" ] } @@ -6814,28 +6846,28 @@ export default { 51 ], "previewMessageCampaignAudience": [ - 290, + 291, { "input": [ - 360, + 361, "PreviewMessageCampaignAudienceInput!" ] } ], "messageSuppressions": [ - 297, + 298, { "input": [ - 361, + 362, "FindMessageSuppressionsInput!" ] } ], "unsubscribeTopics": [ - 298 + 299 ], "myMessageChannels": [ - 281, + 282, { "connectedAccountId": [ 4 @@ -6843,13 +6875,13 @@ export default { } ], "getEmailingDomains": [ - 279 + 280 ], "myConnectedAccounts": [ 247 ], "getToolIndex": [ - 308 + 309 ], "getToolInputSchema": [ 5, @@ -6861,10 +6893,10 @@ export default { } ], "webhooks": [ - 307 + 308 ], "webhook": [ - 307, + 308, { "id": [ 4, @@ -6873,7 +6905,7 @@ export default { } ], "myMessageFolders": [ - 348, + 349, { "messageChannelId": [ 4 @@ -6881,7 +6913,7 @@ export default { } ], "myCalendarChannels": [ - 343, + 344, { "connectedAccountId": [ 4 @@ -6889,17 +6921,17 @@ export default { } ], "minimalMetadata": [ - 354 + 355 ], "appKeyValue": [ - 341, + 342, { "key": [ 1, "String!" ], "scope": [ - 342 + 343 ] } ], @@ -6907,7 +6939,7 @@ export default { 206, { "filter": [ - 362 + 363 ] } ], @@ -6921,13 +6953,13 @@ export default { } ], "findWorkspaceAiStats": [ - 339 + 340 ], "chatThreads": [ - 328 + 329 ], "chatThread": [ - 328, + 329, { "id": [ 4, @@ -6936,7 +6968,7 @@ export default { } ], "chatMessages": [ - 327, + 328, { "threadId": [ 4, @@ -6945,7 +6977,7 @@ export default { } ], "chatStreamCatchupChunks": [ - 332, + 333, { "threadId": [ 4, @@ -6954,13 +6986,13 @@ export default { } ], "getAiSystemPromptPreview": [ - 330 + 331 ], "skills": [ - 326 + 327 ], "skill": [ - 326, + 327, { "id": [ 4, @@ -6969,7 +7001,7 @@ export default { } ], "agentTurns": [ - 338, + 339, { "agentId": [ 4, @@ -7035,43 +7067,43 @@ export default { 212 ], "eventLogs": [ - 325, + 326, { "input": [ - 363, + 364, "EventLogQueryInput!" ] } ], "pieChartData": [ - 319, + 320, { "input": [ - 367, + 368, "PieChartDataInput!" ] } ], "lineChartData": [ - 317, + 318, { "input": [ - 368, + 369, "LineChartDataInput!" ] } ], "barChartData": [ - 314, + 315, { "input": [ - 369, + 370, "BarChartDataInput!" ] } ], "getConnectedImapSmtpCaldavAccount": [ - 305, + 306, { "id": [ 4, @@ -7080,7 +7112,7 @@ export default { } ], "getAutoCompleteAddress": [ - 300, + 301, { "address": [ 1, @@ -7099,7 +7131,7 @@ export default { } ], "getAddressDetails": [ - 302, + 303, { "placeId": [ 1, @@ -7115,12 +7147,15 @@ export default { 273, { "input": [ - 370 + 371 ] } ], "findManyPublicDomains": [ - 277 + 278 + ], + "currentUserApplicationAuthorizations": [ + 274 ], "__typename": [ 1 @@ -7144,10 +7179,10 @@ export default { }, "ObjectFilter": { "and": [ - 358 + 359 ], "or": [ - 358 + 359 ], "id": [ 34 @@ -7198,7 +7233,7 @@ export default { }, "FindMessageSuppressionsInput": { "reason": [ - 295 + 296 ], "searchTerm": [ 1 @@ -7232,10 +7267,10 @@ export default { }, "EventLogQueryInput": { "table": [ - 364 + 365 ], "filters": [ - 365 + 366 ], "first": [ 31 @@ -7256,7 +7291,7 @@ export default { 1 ], "dateRange": [ - 366 + 367 ], "recordId": [ 1 @@ -7323,7 +7358,7 @@ export default { 1 ], "operationTypes": [ - 371 + 372 ], "__typename": [ 1 @@ -7335,7 +7370,7 @@ export default { 3, { "input": [ - 373, + 374, "AddQuerySubscriptionInput!" ] } @@ -7344,7 +7379,7 @@ export default { 3, { "input": [ - 374, + 375, "RemoveQueryFromEventStreamInput!" ] } @@ -7353,7 +7388,7 @@ export default { 153, { "inputs": [ - 375, + 376, "[CreateNavigationMenuItemInput!]!" ] } @@ -7362,7 +7397,7 @@ export default { 153, { "input": [ - 375, + 376, "CreateNavigationMenuItemInput!" ] } @@ -7371,7 +7406,7 @@ export default { 153, { "inputs": [ - 376, + 377, "[UpdateOneNavigationMenuItemInput!]!" ] } @@ -7380,7 +7415,7 @@ export default { 153, { "input": [ - 376, + 377, "UpdateOneNavigationMenuItemInput!" ] } @@ -7415,7 +7450,7 @@ export default { "Float!" ], "fileFolder": [ - 378, + 379, "FileFolder!" ], "fieldMetadataId": [ @@ -7454,7 +7489,7 @@ export default { 150, { "file": [ - 379, + 380, "Upload!" ] } @@ -7463,7 +7498,7 @@ export default { 150, { "file": [ - 379, + 380, "Upload!" ] } @@ -7472,7 +7507,7 @@ export default { 150, { "file": [ - 379, + 380, "Upload!" ] } @@ -7481,7 +7516,7 @@ export default { 150, { "file": [ - 379, + 380, "Upload!" ] } @@ -7490,7 +7525,7 @@ export default { 150, { "file": [ - 379, + 380, "Upload!" ] } @@ -7499,7 +7534,7 @@ export default { 150, { "file": [ - 379, + 380, "Upload!" ], "fieldMetadataId": [ @@ -7512,7 +7547,7 @@ export default { 150, { "file": [ - 379, + 380, "Upload!" ], "fieldMetadataUniversalIdentifier": [ @@ -7525,7 +7560,7 @@ export default { 58, { "input": [ - 380, + 381, "CreateViewFilterGroupInput!" ] } @@ -7538,7 +7573,7 @@ export default { "String!" ], "input": [ - 381, + 382, "UpdateViewFilterGroupInput!" ] } @@ -7565,7 +7600,7 @@ export default { 60, { "input": [ - 382, + 383, "CreateViewFilterInput!" ] } @@ -7574,7 +7609,7 @@ export default { 60, { "input": [ - 383, + 384, "UpdateViewFilterInput!" ] } @@ -7583,7 +7618,7 @@ export default { 60, { "input": [ - 385, + 386, "DeleteViewFilterInput!" ] } @@ -7592,7 +7627,7 @@ export default { 60, { "input": [ - 386, + 387, "DestroyViewFilterInput!" ] } @@ -7601,7 +7636,7 @@ export default { 66, { "input": [ - 387, + 388, "CreateViewInput!" ] } @@ -7614,7 +7649,7 @@ export default { "String!" ], "input": [ - 388, + 389, "UpdateViewInput!" ] } @@ -7641,7 +7676,7 @@ export default { 66, { "input": [ - 389, + 390, "UpsertViewWidgetInput!" ] } @@ -7650,7 +7685,7 @@ export default { 63, { "input": [ - 395, + 396, "CreateViewSortInput!" ] } @@ -7659,7 +7694,7 @@ export default { 63, { "input": [ - 396, + 397, "UpdateViewSortInput!" ] } @@ -7668,7 +7703,7 @@ export default { 3, { "input": [ - 398, + 399, "DeleteViewSortInput!" ] } @@ -7677,7 +7712,7 @@ export default { 3, { "input": [ - 399, + 400, "DestroyViewSortInput!" ] } @@ -7686,7 +7721,7 @@ export default { 56, { "input": [ - 400, + 401, "UpdateViewFieldInput!" ] } @@ -7695,7 +7730,7 @@ export default { 56, { "input": [ - 402, + 403, "CreateViewFieldInput!" ] } @@ -7704,7 +7739,7 @@ export default { 56, { "inputs": [ - 402, + 403, "[CreateViewFieldInput!]!" ] } @@ -7713,7 +7748,7 @@ export default { 56, { "input": [ - 403, + 404, "DeleteViewFieldInput!" ] } @@ -7722,7 +7757,7 @@ export default { 56, { "input": [ - 404, + 405, "DestroyViewFieldInput!" ] } @@ -7731,7 +7766,7 @@ export default { 65, { "input": [ - 405, + 406, "UpdateViewFieldGroupInput!" ] } @@ -7740,7 +7775,7 @@ export default { 65, { "input": [ - 407, + 408, "CreateViewFieldGroupInput!" ] } @@ -7749,7 +7784,7 @@ export default { 65, { "inputs": [ - 407, + 408, "[CreateViewFieldGroupInput!]!" ] } @@ -7758,7 +7793,7 @@ export default { 65, { "input": [ - 408, + 409, "DeleteViewFieldGroupInput!" ] } @@ -7767,7 +7802,7 @@ export default { 65, { "input": [ - 409, + 410, "DestroyViewFieldGroupInput!" ] } @@ -7776,7 +7811,7 @@ export default { 66, { "input": [ - 410, + 411, "UpsertFieldsWidgetInput!" ] } @@ -7785,7 +7820,7 @@ export default { 7, { "input": [ - 413, + 414, "CreateApiKeyInput!" ] } @@ -7794,7 +7829,7 @@ export default { 7, { "input": [ - 414, + 415, "UpdateApiKeyInput!" ] } @@ -7803,7 +7838,7 @@ export default { 7, { "input": [ - 415, + 416, "RevokeApiKeyInput!" ] } @@ -7970,7 +8005,7 @@ export default { 149, { "input": [ - 416, + 417, "CreateApprovedAccessDomainInput!" ] } @@ -7979,7 +8014,7 @@ export default { 3, { "input": [ - 417, + 418, "DeleteApprovedAccessDomainInput!" ] } @@ -7988,7 +8023,7 @@ export default { 149, { "input": [ - 418, + 419, "ValidateApprovedAccessDomainInput!" ] } @@ -7997,7 +8032,7 @@ export default { 123, { "input": [ - 419, + 420, "CreatePageLayoutTabInput!" ] } @@ -8010,7 +8045,7 @@ export default { "String!" ], "input": [ - 420, + 421, "UpdatePageLayoutTabInput!" ] } @@ -8028,7 +8063,7 @@ export default { 124, { "input": [ - 421, + 422, "CreatePageLayoutInput!" ] } @@ -8041,7 +8076,7 @@ export default { "String!" ], "input": [ - 422, + 423, "UpdatePageLayoutInput!" ] } @@ -8063,7 +8098,7 @@ export default { "String!" ], "input": [ - 423, + 424, "UpdatePageLayoutWithTabsInput!" ] } @@ -8099,7 +8134,7 @@ export default { 83, { "input": [ - 427, + 428, "CreatePageLayoutWidgetInput!" ] } @@ -8112,7 +8147,7 @@ export default { "String!" ], "input": [ - 428, + 429, "UpdatePageLayoutWidgetInput!" ] } @@ -8130,7 +8165,7 @@ export default { 11, { "input": [ - 429, + 430, "CreateAgentInput!" ] } @@ -8139,7 +8174,7 @@ export default { 11, { "input": [ - 430, + 431, "UpdateAgentInput!" ] } @@ -8148,7 +8183,7 @@ export default { 11, { "input": [ - 357, + 358, "AgentIdInput!" ] } @@ -8157,7 +8192,7 @@ export default { 28, { "input": [ - 431, + 432, "CreateOneObjectInput!" ] } @@ -8166,7 +8201,7 @@ export default { 28, { "input": [ - 433, + 434, "DeleteOneObjectInput!" ] } @@ -8175,7 +8210,7 @@ export default { 28, { "input": [ - 434, + 435, "UpdateOneObjectInput!" ] } @@ -8184,7 +8219,7 @@ export default { 26, { "input": [ - 436, + 437, "CreateOneIndexInput!" ] } @@ -8193,7 +8228,7 @@ export default { 26, { "input": [ - 439, + 440, "DeleteOneIndexInput!" ] } @@ -8202,7 +8237,7 @@ export default { 22, { "input": [ - 359, + 360, "LogicFunctionIdInput!" ] } @@ -8211,7 +8246,7 @@ export default { 22, { "input": [ - 440, + 441, "CreateLogicFunctionFromSourceInput!" ] } @@ -8220,7 +8255,7 @@ export default { 145, { "input": [ - 441, + 442, "ExecuteOneLogicFunctionInput!" ] } @@ -8229,7 +8264,7 @@ export default { 3, { "input": [ - 442, + 443, "UpdateLogicFunctionFromSourceInput!" ] } @@ -8238,7 +8273,7 @@ export default { 15, { "input": [ - 444, + 445, "CreateCommandMenuItemInput!" ] } @@ -8247,7 +8282,7 @@ export default { 15, { "input": [ - 445, + 446, "UpdateCommandMenuItemInput!" ] } @@ -8274,7 +8309,7 @@ export default { 14, { "input": [ - 446, + 447, "CreateFrontComponentInput!" ] } @@ -8283,7 +8318,7 @@ export default { 14, { "input": [ - 447, + 448, "UpdateFrontComponentInput!" ] } @@ -8301,7 +8336,7 @@ export default { 72, { "data": [ - 449, + 450, "ActivateWorkspaceInput!" ] } @@ -8310,7 +8345,7 @@ export default { 72, { "data": [ - 450, + 451, "UpdateWorkspaceInput!" ] } @@ -8341,7 +8376,7 @@ export default { 203, { "input": [ - 451, + 452, "CreateApplicationRegistrationInput!" ] } @@ -8350,7 +8385,7 @@ export default { 78, { "input": [ - 452, + 453, "UpdateApplicationRegistrationInput!" ] } @@ -8377,7 +8412,7 @@ export default { 2, { "input": [ - 454, + 455, "CreateApplicationRegistrationVariableInput!" ] } @@ -8386,7 +8421,7 @@ export default { 2, { "input": [ - 455, + 456, "UpdateApplicationRegistrationVariableInput!" ] } @@ -8404,7 +8439,7 @@ export default { 78, { "file": [ - 379, + 380, "Upload!" ], "universalIdentifier": [ @@ -8466,7 +8501,7 @@ export default { "UUID!" ], "input": [ - 457, + 458, "UpdateApplicationInput!" ] } @@ -8487,7 +8522,7 @@ export default { 24, { "input": [ - 458, + 459, "CreateOneFieldMetadataInput!" ] } @@ -8496,7 +8531,7 @@ export default { 24, { "input": [ - 460, + 461, "UpdateOneFieldMetadataInput!" ] } @@ -8505,7 +8540,7 @@ export default { 24, { "input": [ - 462, + 463, "DeleteOneFieldInput!" ] } @@ -8514,7 +8549,7 @@ export default { 62, { "input": [ - 463, + 464, "CreateViewGroupInput!" ] } @@ -8523,7 +8558,7 @@ export default { 62, { "inputs": [ - 463, + 464, "[CreateViewGroupInput!]!" ] } @@ -8532,7 +8567,7 @@ export default { 62, { "input": [ - 464, + 465, "UpdateViewGroupInput!" ] } @@ -8541,7 +8576,7 @@ export default { 62, { "inputs": [ - 464, + 465, "[UpdateViewGroupInput!]!" ] } @@ -8550,7 +8585,7 @@ export default { 62, { "input": [ - 466, + 467, "DeleteViewGroupInput!" ] } @@ -8559,7 +8594,7 @@ export default { 62, { "input": [ - 467, + 468, "DestroyViewGroupInput!" ] } @@ -8581,7 +8616,7 @@ export default { 51, { "createRoleInput": [ - 468, + 469, "CreateRoleInput!" ] } @@ -8590,7 +8625,7 @@ export default { 51, { "updateRoleInput": [ - 469, + 470, "UpdateRoleInput!" ] } @@ -8608,7 +8643,7 @@ export default { 48, { "upsertObjectPermissionsInput": [ - 471, + 472, "UpsertObjectPermissionsInput!" ] } @@ -8617,7 +8652,7 @@ export default { 49, { "upsertPermissionFlagsInput": [ - 473, + 474, "UpsertPermissionFlagsInput!" ] } @@ -8626,7 +8661,7 @@ export default { 43, { "upsertFieldPermissionsInput": [ - 474, + 475, "UpsertFieldPermissionsInput!" ] } @@ -8635,7 +8670,7 @@ export default { 242, { "input": [ - 476, + 477, "UpsertRowLevelPermissionPredicatesInput!" ] } @@ -8663,46 +8698,46 @@ export default { } ], "sendEmailViaEmailingDomain": [ - 291, + 292, { "input": [ - 479, + 480, "SendEmailViaDomainInput!" ] } ], "sendMessageCampaign": [ - 293, + 294, { "input": [ - 480, + 481, "SendMessageCampaignInput!" ] } ], "sendMessageCampaignTest": [ - 291, + 292, { "input": [ - 481, + 482, "SendMessageCampaignTestInput!" ] } ], "createUnsubscribeTopic": [ - 298, + 299, { "input": [ - 482, + 483, "CreateUnsubscribeTopicInput!" ] } ], "updateUnsubscribeTopic": [ - 298, + 299, { "input": [ - 483, + 484, "UpdateUnsubscribeTopicInput!" ] } @@ -8717,34 +8752,34 @@ export default { } ], "updateMessageChannel": [ - 281, + 282, { "input": [ - 484, + 485, "UpdateMessageChannelInput!" ] } ], "createEmailGroupChannel": [ - 289, + 290, { "input": [ - 486, + 487, "CreateEmailGroupChannelInput!" ] } ], "updateEmailGroupChannel": [ - 281, + 282, { "input": [ - 487, + 488, "UpdateEmailGroupChannelInput!" ] } ], "deleteEmailGroupChannel": [ - 281, + 282, { "id": [ 4, @@ -8753,10 +8788,10 @@ export default { } ], "createEmailingDomain": [ - 279, + 280, { "input": [ - 488, + 489, "CreateEmailingDomainInput!" ] } @@ -8771,7 +8806,7 @@ export default { } ], "verifyEmailingDomain": [ - 279, + 280, { "id": [ 1, @@ -8789,34 +8824,34 @@ export default { } ], "runAgent": [ - 310, + 311, { "input": [ - 489, + 490, "RunAgentInput!" ] } ], "createWebhook": [ - 307, + 308, { "input": [ - 490, + 491, "CreateWebhookInput!" ] } ], "updateWebhook": [ - 307, + 308, { "input": [ - 491, + 492, "UpdateWebhookInput!" ] } ], "deleteWebhook": [ - 307, + 308, { "id": [ 4, @@ -8825,37 +8860,37 @@ export default { } ], "updateMessageFolder": [ - 348, + 349, { "input": [ - 493, + 494, "UpdateMessageFolderInput!" ] } ], "updateMessageFolders": [ - 348, + 349, { "input": [ - 495, + 496, "UpdateMessageFoldersInput!" ] } ], "updateCalendarChannel": [ - 343, + 344, { "input": [ - 496, + 497, "UpdateCalendarChannelInput!" ] } ], "setAppKeyValue": [ - 341, + 342, { "input": [ - 498, + 499, "SetAppKeyValueInput!" ] } @@ -8868,24 +8903,24 @@ export default { "String!" ], "scope": [ - 342 + 343 ] } ], "enqueueJob": [ - 340, + 341, { "input": [ - 499, + 500, "EnqueueJobInput!" ] } ], "createChatThread": [ - 328 + 329 ], "sendChatMessage": [ - 333, + 334, { "threadId": [ 4, @@ -8906,13 +8941,13 @@ export default { 1 ], "fileAttachments": [ - 500, + 501, "[FileAttachmentInput!]" ] } ], "retryChatMessage": [ - 333, + 334, { "threadId": [ 4, @@ -8924,7 +8959,7 @@ export default { } ], "answerAgentChatQuestion": [ - 333, + 334, { "threadId": [ 4, @@ -8935,7 +8970,7 @@ export default { "UUID!" ], "answers": [ - 501, + 502, "[AgentChatQuestionAnswerInput!]!" ], "modelId": [ @@ -8953,7 +8988,7 @@ export default { } ], "renameChatThread": [ - 328, + 329, { "id": [ 4, @@ -8966,7 +9001,7 @@ export default { } ], "archiveChatThread": [ - 328, + 329, { "id": [ 4, @@ -8975,7 +9010,7 @@ export default { } ], "unarchiveChatThread": [ - 328, + 329, { "id": [ 4, @@ -9002,7 +9037,7 @@ export default { } ], "startWorkspaceSetupChat": [ - 335, + 336, { "companyContext": [ 5 @@ -9010,25 +9045,25 @@ export default { } ], "createSkill": [ - 326, + 327, { "input": [ - 502, + 503, "CreateSkillInput!" ] } ], "updateSkill": [ - 326, + 327, { "input": [ - 503, + 504, "UpdateSkillInput!" ] } ], "deleteSkill": [ - 326, + 327, { "id": [ 4, @@ -9037,7 +9072,7 @@ export default { } ], "activateSkill": [ - 326, + 327, { "id": [ 4, @@ -9046,7 +9081,7 @@ export default { } ], "deactivateSkill": [ - 326, + 327, { "id": [ 4, @@ -9055,7 +9090,7 @@ export default { } ], "evaluateAgentTurn": [ - 337, + 338, { "turnId": [ 4, @@ -9064,7 +9099,7 @@ export default { } ], "runEvaluationInput": [ - 338, + 339, { "agentId": [ 4, @@ -9080,7 +9115,7 @@ export default { 255, { "input": [ - 504, + 505, "GetAuthorizationUrlForSSOInput!" ] } @@ -9246,7 +9281,7 @@ export default { 258, { "input": [ - 505 + 506 ] } ], @@ -9258,7 +9293,7 @@ export default { "String!" ], "file": [ - 379, + 380, "Upload!" ] } @@ -9421,7 +9456,7 @@ export default { 3, { "input": [ - 506, + 507, "UpdateWorkspaceMemberSettingsInput!" ] } @@ -9455,7 +9490,7 @@ export default { 213, { "input": [ - 507, + 508, "SetupOIDCSsoInput!" ] } @@ -9464,7 +9499,7 @@ export default { 213, { "input": [ - 508, + 509, "SetupSAMLSsoInput!" ] } @@ -9473,7 +9508,7 @@ export default { 209, { "input": [ - 509, + 510, "DeleteSsoInput!" ] } @@ -9482,13 +9517,13 @@ export default { 210, { "input": [ - 510, + 511, "EditSsoInput!" ] } ], "createObjectEvent": [ - 322, + 323, { "event": [ 1, @@ -9508,10 +9543,10 @@ export default { } ], "trackAnalytics": [ - 322, + 323, { "type": [ - 511, + 512, "AnalyticsType!" ], "name": [ @@ -9526,7 +9561,7 @@ export default { } ], "duplicateDashboard": [ - 320, + 321, { "id": [ 4, @@ -9551,25 +9586,25 @@ export default { 270 ], "createCalendarEvent": [ - 312, + 313, { "input": [ - 512, + 513, "CreateCalendarEventInput!" ] } ], "sendEmail": [ - 321, + 322, { "input": [ - 513, + 514, "SendEmailInput!" ] } ], "startChannelSync": [ - 311, + 312, { "connectedAccountId": [ 4, @@ -9578,14 +9613,14 @@ export default { } ], "saveImapSmtpCaldavAccount": [ - 306, + 307, { "handle": [ 1, "String!" ], "connectionParameters": [ - 515, + 516, "EmailAccountConnectionParameters!" ], "id": [ @@ -9597,13 +9632,13 @@ export default { 173, { "input": [ - 517, + 518, "UpdateLabPublicFeatureFlagInput!" ] } ], "createPublicDomain": [ - 277, + 278, { "domain": [ 1, @@ -9634,7 +9669,7 @@ export default { } ], "createDevelopmentApplication": [ - 274, + 275, { "universalIdentifier": [ 1, @@ -9647,7 +9682,7 @@ export default { } ], "syncApplication": [ - 275, + 276, { "manifest": [ 5, @@ -9659,10 +9694,10 @@ export default { } ], "uploadApplicationFile": [ - 276, + 277, { "file": [ - 379, + 380, "Upload!" ], "applicationUniversalIdentifier": [ @@ -9670,7 +9705,7 @@ export default { "String!" ], "fileFolder": [ - 378, + 379, "FileFolder!" ], "filePath": [ @@ -9679,6 +9714,15 @@ export default { ] } ], + "revokeApplicationAuthorization": [ + 3, + { + "applicationAuthorizationId": [ + 4, + "UUID!" + ] + } + ], "generateApplicationToken": [ 13, { @@ -9775,7 +9819,7 @@ export default { 4 ], "update": [ - 377 + 378 ], "__typename": [ 1 @@ -9886,7 +9930,7 @@ export default { 4 ], "update": [ - 384 + 385 ], "__typename": [ 1 @@ -10057,20 +10101,20 @@ export default { 4 ], "view": [ - 390 - ], - "viewFields": [ 391 ], - "viewFilters": [ + "viewFields": [ 392 ], - "viewFilterGroups": [ + "viewFilters": [ 393 ], - "viewSorts": [ + "viewFilterGroups": [ 394 ], + "viewSorts": [ + 395 + ], "__typename": [ 1 ] @@ -10218,7 +10262,7 @@ export default { 4 ], "update": [ - 397 + 398 ], "__typename": [ 1 @@ -10256,7 +10300,7 @@ export default { 4 ], "update": [ - 401 + 402 ], "__typename": [ 1 @@ -10332,7 +10376,7 @@ export default { 4 ], "update": [ - 406 + 407 ], "__typename": [ 1 @@ -10396,10 +10440,10 @@ export default { 4 ], "groups": [ - 411 + 412 ], "fields": [ - 412 + 413 ], "__typename": [ 1 @@ -10419,7 +10463,7 @@ export default { 3 ], "fields": [ - 412 + 413 ], "__typename": [ 1 @@ -10587,7 +10631,7 @@ export default { 4 ], "tabs": [ - 424 + 425 ], "__typename": [ 1 @@ -10610,7 +10654,7 @@ export default { 87 ], "widgets": [ - 425 + 426 ], "__typename": [ 1 @@ -10633,7 +10677,7 @@ export default { 4 ], "gridPosition": [ - 426 + 427 ], "position": [ 5 @@ -10682,7 +10726,7 @@ export default { 4 ], "gridPosition": [ - 426 + 427 ], "position": [ 5 @@ -10708,7 +10752,7 @@ export default { 4 ], "gridPosition": [ - 426 + 427 ], "position": [ 5 @@ -10801,7 +10845,7 @@ export default { }, "CreateOneObjectInput": { "object": [ - 432 + 433 ], "__typename": [ 1 @@ -10861,7 +10905,7 @@ export default { }, "UpdateOneObjectInput": { "update": [ - 435 + 436 ], "id": [ 4 @@ -10919,7 +10963,7 @@ export default { }, "CreateOneIndexInput": { "index": [ - 437 + 438 ], "__typename": [ 1 @@ -10930,7 +10974,7 @@ export default { 4 ], "fields": [ - 438 + 439 ], "indexType": [ 27 @@ -11015,7 +11059,7 @@ export default { 4 ], "update": [ - 443 + 444 ], "__typename": [ 1 @@ -11175,7 +11219,7 @@ export default { 4 ], "update": [ - 448 + 449 ], "__typename": [ 1 @@ -11302,7 +11346,7 @@ export default { 1 ], "update": [ - 453 + 454 ], "__typename": [ 1 @@ -11356,7 +11400,7 @@ export default { 1 ], "update": [ - 456 + 457 ], "__typename": [ 1 @@ -11386,7 +11430,7 @@ export default { }, "CreateOneFieldMetadataInput": { "field": [ - 459 + 460 ], "__typename": [ 1 @@ -11459,7 +11503,7 @@ export default { 4 ], "update": [ - 461 + 462 ], "__typename": [ 1 @@ -11554,7 +11598,7 @@ export default { 4 ], "update": [ - 465 + 466 ], "__typename": [ 1 @@ -11639,7 +11683,7 @@ export default { }, "UpdateRoleInput": { "update": [ - 470 + 471 ], "id": [ 4 @@ -11694,7 +11738,7 @@ export default { 4 ], "objectPermissions": [ - 472 + 473 ], "__typename": [ 1 @@ -11736,7 +11780,7 @@ export default { 4 ], "fieldPermissions": [ - 475 + 476 ], "__typename": [ 1 @@ -11767,10 +11811,10 @@ export default { 4 ], "predicates": [ - 477 + 478 ], "predicateGroups": [ - 478 + 479 ], "__typename": [ 1 @@ -11896,7 +11940,7 @@ export default { 1 ], "visibility": [ - 299 + 300 ], "__typename": [ 1 @@ -11913,7 +11957,7 @@ export default { 1 ], "visibility": [ - 299 + 300 ], "__typename": [ 1 @@ -11924,7 +11968,7 @@ export default { 4 ], "update": [ - 485 + 486 ], "__typename": [ 1 @@ -11932,16 +11976,16 @@ export default { }, "UpdateMessageChannelInputUpdates": { "visibility": [ - 282 + 283 ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 284 + 285 ], "messageFolderImportPolicy": [ - 285 + 286 ], "isSyncEnabled": [ 3 @@ -12022,7 +12066,7 @@ export default { 4 ], "update": [ - 492 + 493 ], "__typename": [ 1 @@ -12050,7 +12094,7 @@ export default { 4 ], "update": [ - 494 + 495 ], "__typename": [ 1 @@ -12069,7 +12113,7 @@ export default { 4 ], "update": [ - 494 + 495 ], "__typename": [ 1 @@ -12080,7 +12124,7 @@ export default { 4 ], "update": [ - 497 + 498 ], "__typename": [ 1 @@ -12088,13 +12132,13 @@ export default { }, "UpdateCalendarChannelInputUpdates": { "visibility": [ - 346 + 347 ], "isContactAutoCreationEnabled": [ 3 ], "contactAutoCreationPolicy": [ - 347 + 348 ], "isSyncEnabled": [ 3 @@ -12111,7 +12155,7 @@ export default { 5 ], "scope": [ - 342 + 343 ], "__typename": [ 1 @@ -12365,7 +12409,7 @@ export default { 1 ], "files": [ - 514 + 515 ], "__typename": [ 1 @@ -12387,13 +12431,13 @@ export default { 1 ], "IMAP": [ - 516 + 517 ], "SMTP": [ - 516 + 517 ], "CALDAV": [ - 516 + 517 ], "__typename": [ 1 @@ -12444,13 +12488,13 @@ export default { 243, { "input": [ - 519, + 520, "LogicFunctionLogsInput!" ] } ], "onAgentChatEvent": [ - 334, + 335, { "threadId": [ 4, @@ -12459,10 +12503,10 @@ export default { } ], "eventLogsLive": [ - 323, + 324, { "table": [ - 364, + 365, "EventLogTable!" ] } diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 92495ea6b6..80aaea2e4e 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -321,6 +321,19 @@ export type Application = { yarnLockFileId?: Maybe; }; +export type ApplicationAuthorization = { + __typename?: 'ApplicationAuthorization'; + applicationId: Scalars['UUID']['output']; + applicationName: Scalars['String']['output']; + applicationUniversalIdentifier?: Maybe; + createdAt: Scalars['DateTime']['output']; + id: Scalars['UUID']['output']; + lastAuthorizedAt?: Maybe; + lastUsedAt: Scalars['DateTime']['output']; + scopes?: Maybe>; + workspaceId: Scalars['UUID']['output']; +}; + export type ApplicationConnectionProvider = { __typename?: 'ApplicationConnectionProvider'; applicationId: Scalars['String']['output']; @@ -2723,6 +2736,7 @@ export type Mutation = { retryChatMessage: SendChatMessageResult; revokeAllOtherUserSessions: Scalars['Int']['output']; revokeApiKey?: Maybe; + revokeApplicationAuthorization: Scalars['Boolean']['output']; revokeUserSession: Scalars['Boolean']['output']; rotateApplicationRegistrationClientSecret: RotateClientSecret; runAgent: RunAgentResult; @@ -3501,6 +3515,11 @@ export type MutationRevokeApiKeyArgs = { }; +export type MutationRevokeApplicationAuthorizationArgs = { + applicationAuthorizationId: Scalars['UUID']['input']; +}; + + export type MutationRevokeUserSessionArgs = { userSessionId: Scalars['UUID']['input']; }; @@ -4513,6 +4532,7 @@ export type Query = { commandMenuItem?: Maybe; commandMenuItems: Array; currentUser: User; + currentUserApplicationAuthorizations: Array; currentUserSessions: Array; currentWorkspace: Workspace; enterpriseCheckoutSession?: Maybe; diff --git a/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts index e9686705e8..4db1c5b2ea 100644 --- a/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts +++ b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts @@ -43,6 +43,10 @@ const WORKSPACE_SCOPED_EXEMPTIONS = new Set([ // 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', diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785681272278-create-application-authorization-core-table.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785681272278-create-application-authorization-core-table.ts new file mode 100644 index 0000000000..599d7b1212 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785681272278-create-application-authorization-core-table.ts @@ -0,0 +1,55 @@ +import { type QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.27.0', 1785681272278) +export class CreateApplicationAuthorizationCoreTableFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "core"."applicationAuthorization" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "userId" uuid NOT NULL, + "workspaceId" uuid NOT NULL, + "applicationId" uuid NOT NULL, + "userWorkspaceId" uuid NOT NULL, + "scopes" text array, + "lastAuthorizedAt" TIMESTAMP WITH TIME ZONE, + "lastUsedAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "revokedAt" TIMESTAMP WITH TIME ZONE, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_applicationAuthorization_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_APPLICATION_AUTHORIZATION_USER_ID" FOREIGN KEY ("userId") + REFERENCES "core"."user"("id") ON DELETE CASCADE, + CONSTRAINT "FK_APPLICATION_AUTHORIZATION_WORKSPACE_ID" FOREIGN KEY ("workspaceId") + REFERENCES "core"."workspace"("id") ON DELETE CASCADE, + CONSTRAINT "FK_APPLICATION_AUTHORIZATION_APPLICATION_ID" FOREIGN KEY ("applicationId") + REFERENCES "core"."application"("id") ON DELETE CASCADE, + CONSTRAINT "FK_APPLICATION_AUTHORIZATION_USER_WORKSPACE_ID" FOREIGN KEY ("userWorkspaceId") + REFERENCES "core"."userWorkspace"("id") ON DELETE CASCADE + )`, + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_APPLICATION_AUTHORIZATION_USER_APPLICATION_UNIQUE" ON "core"."applicationAuthorization" ("userId", "applicationId")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_APPLICATION_AUTHORIZATION_WORKSPACE_ID" ON "core"."applicationAuthorization" ("workspaceId")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_APPLICATION_AUTHORIZATION_APPLICATION_ID" ON "core"."applicationAuthorization" ("applicationId")`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_APPLICATION_AUTHORIZATION_USER_WORKSPACE_ID" ON "core"."applicationAuthorization" ("userWorkspaceId")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS "core"."applicationAuthorization"`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index bbfcd67962..32dab08a85 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -133,6 +133,7 @@ import { AddIsHiddenToAgentMessageFastInstanceCommand } from './2-25/2-25-instan import { AddConnectedAccountHandleProviderIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-instance-command-fast-1785420705255-add-connected-account-handle-provider-index'; import { AddOpenRecordInToObjectMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785504900000-add-open-record-in-to-object-metadata'; import { CreateUserSessionCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table'; +import { CreateApplicationAuthorizationCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785681272278-create-application-authorization-core-table'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -268,4 +269,5 @@ export const INSTANCE_COMMANDS = [ AddConnectedAccountHandleProviderIndexFastInstanceCommand, AddOpenRecordInToObjectMetadataFastInstanceCommand, CreateUserSessionCoreTableFastInstanceCommand, + CreateApplicationAuthorizationCoreTableFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-authorization/__tests__/application-authorization.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-authorization/__tests__/application-authorization.service.spec.ts new file mode 100644 index 0000000000..d43aeea61a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-authorization/__tests__/application-authorization.service.spec.ts @@ -0,0 +1,226 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { IsNull, type Repository, type UpdateResult } from 'typeorm'; + +import { ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity'; +import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service'; + +describe('ApplicationAuthorizationService', () => { + let service: ApplicationAuthorizationService; + let repository: jest.Mocked>; + + const queryBuilder = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + orIgnore: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ identifiers: [] }), + }; + + const userId = 'user-1'; + const otherUserId = 'user-2'; + const workspaceId = 'workspace-1'; + const userWorkspaceId = 'user-workspace-1'; + const applicationId = 'application-1'; + const authorizationId = 'authorization-1'; + + const buildUpdateResult = (affected: number): UpdateResult => ({ + affected, + raw: [], + generatedMaps: [], + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + queryBuilder.innerJoinAndSelect.mockReturnThis(); + queryBuilder.where.mockReturnThis(); + queryBuilder.andWhere.mockReturnThis(); + queryBuilder.orderBy.mockReturnThis(); + queryBuilder.getMany.mockResolvedValue([]); + queryBuilder.insert.mockReturnThis(); + queryBuilder.values.mockReturnThis(); + queryBuilder.orIgnore.mockReturnThis(); + queryBuilder.execute.mockResolvedValue({ identifiers: [] }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApplicationAuthorizationService, + { + provide: getRepositoryToken(ApplicationAuthorizationEntity), + useValue: { + upsert: jest.fn(), + findOneBy: jest.fn(), + update: jest.fn(), + createQueryBuilder: jest.fn(() => queryBuilder), + }, + }, + ], + }).compile(); + + service = module.get(ApplicationAuthorizationService); + repository = module.get( + getRepositoryToken(ApplicationAuthorizationEntity), + ) as jest.Mocked>; + }); + + describe('recordAuthorization', () => { + it('should upsert on the user and application pair so re-authorizing does not duplicate the row', async () => { + await service.recordAuthorization({ + userId, + workspaceId, + userWorkspaceId, + applicationId, + scopes: ['api', 'profile'], + }); + + expect(repository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + userId, + workspaceId, + userWorkspaceId, + applicationId, + scopes: ['api', 'profile'], + }), + expect.objectContaining({ + conflictPaths: ['userId', 'applicationId'], + }), + ); + }); + + it('should clear revokedAt when the user authorizes again', async () => { + await service.recordAuthorization({ + userId, + workspaceId, + userWorkspaceId, + applicationId, + scopes: [], + }); + + expect(repository.upsert).toHaveBeenCalledWith( + expect.objectContaining({ revokedAt: null }), + expect.anything(), + ); + }); + }); + + describe('backfillAuthorizationFromRefreshToken', () => { + const backfill = () => + service.backfillAuthorizationFromRefreshToken({ + userId, + workspaceId, + userWorkspaceId, + applicationId, + }); + + it('should leave the consent unrecorded rather than guessing it', async () => { + await backfill(); + + expect(queryBuilder.values).toHaveBeenCalledWith( + expect.objectContaining({ scopes: null, lastAuthorizedAt: null }), + ); + }); + + it('should never overwrite a row written by a real consent', async () => { + await backfill(); + + expect(queryBuilder.orIgnore).toHaveBeenCalled(); + expect(repository.upsert).not.toHaveBeenCalled(); + }); + }); + + describe('findByUserAndApplication', () => { + it('should not filter on revokedAt so callers can tell a revoked grant from a missing one', async () => { + repository.findOneBy.mockResolvedValue(null); + + await service.findByUserAndApplication({ userId, applicationId }); + + expect(repository.findOneBy).toHaveBeenCalledWith({ + userId, + applicationId, + }); + }); + }); + + describe('findActiveAuthorizationsForUser', () => { + it('should only return unrevoked authorizations whose application still exists', async () => { + await service.findActiveAuthorizationsForUser(userId); + + expect(queryBuilder.innerJoinAndSelect).toHaveBeenCalledWith( + 'applicationAuthorization.application', + 'application', + ); + expect(queryBuilder.where).toHaveBeenCalledWith( + 'applicationAuthorization.userId = :userId', + { userId }, + ); + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + 'applicationAuthorization.revokedAt IS NULL', + ); + }); + + it('should put the most recently used authorization first', async () => { + await service.findActiveAuthorizationsForUser(userId); + + expect(queryBuilder.orderBy).toHaveBeenCalledWith( + 'applicationAuthorization.lastUsedAt', + 'DESC', + ); + }); + }); + + describe('revokeAuthorizationById', () => { + it('should scope the update by userId so an id from another user matches nothing', async () => { + repository.update.mockResolvedValue(buildUpdateResult(0)); + + const revoked = await service.revokeAuthorizationById({ + authorizationId, + userId: otherUserId, + }); + + expect(repository.update).toHaveBeenCalledWith( + { id: authorizationId, userId: otherUserId, revokedAt: IsNull() }, + expect.objectContaining({ revokedAt: expect.any(Date) }), + ); + expect(revoked).toBe(false); + }); + + it('should report true when the row was still active', async () => { + repository.update.mockResolvedValue(buildUpdateResult(1)); + + expect( + await service.revokeAuthorizationById({ authorizationId, userId }), + ).toBe(true); + }); + + it('should report false when the row was already revoked', async () => { + repository.update.mockResolvedValue(buildUpdateResult(0)); + + expect( + await service.revokeAuthorizationById({ authorizationId, userId }), + ).toBe(false); + }); + }); + + describe('revokeAuthorizationForApplication', () => { + it('should revoke the live row for that user and application', async () => { + repository.update.mockResolvedValue(buildUpdateResult(1)); + + const revoked = await service.revokeAuthorizationForApplication({ + userId, + applicationId, + }); + + expect(repository.update).toHaveBeenCalledWith( + { userId, applicationId, revokedAt: IsNull() }, + expect.objectContaining({ revokedAt: expect.any(Date) }), + ); + expect(revoked).toBe(true); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.entity.ts new file mode 100644 index 0000000000..3275c1ba13 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.entity.ts @@ -0,0 +1,122 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + UpdateDateColumn, +} from 'typeorm'; + +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +// One row per user who has completed an OAuth authorization_code exchange for +// an application. Application tokens are stateless JWTs carrying the user as a +// claim, so without this row the server has no record that the authorization +// happened and nothing to list or revoke. Only authorization_code issues a +// refresh token; client_credentials returns an access token alone and involves +// no user, so it has no row here. +@Entity({ name: 'applicationAuthorization', schema: 'core' }) +// Re-authorizing the same application updates this row rather than adding a +// second one, so a user never accumulates duplicate entries for one app. +@Index( + 'IDX_APPLICATION_AUTHORIZATION_USER_APPLICATION_UNIQUE', + ['userId', 'applicationId'], + { unique: true }, +) +export class ApplicationAuthorizationEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => UserEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'userId', + foreignKeyConstraintName: 'FK_APPLICATION_AUTHORIZATION_USER_ID', + }) + user: Relation; + + // No index of its own: it leads the unique index declared on the class, which + // already serves both the per-user listing and the cascade delete. + @Column({ type: 'uuid' }) + userId: string; + + @ManyToOne(() => WorkspaceEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'workspaceId', + foreignKeyConstraintName: 'FK_APPLICATION_AUTHORIZATION_WORKSPACE_ID', + }) + workspace: Relation; + + @Index('IDX_APPLICATION_AUTHORIZATION_WORKSPACE_ID') + @Column({ type: 'uuid' }) + workspaceId: string; + + // Uninstalling deletes the application row, which already invalidates every + // token issued for it. Cascading here stops the grants outliving the install + // they describe. + @ManyToOne(() => ApplicationEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'applicationId', + foreignKeyConstraintName: 'FK_APPLICATION_AUTHORIZATION_APPLICATION_ID', + }) + application: Relation; + + @Index('IDX_APPLICATION_AUTHORIZATION_APPLICATION_ID') + @Column({ type: 'uuid' }) + applicationId: string; + + // Cascades only on a hard delete. Removing a member soft-deletes the + // membership instead, which leaves this row intact, so the refresh path + // rechecks the membership rather than trusting the grant to have gone. + @ManyToOne(() => UserWorkspaceEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'userWorkspaceId', + foreignKeyConstraintName: 'FK_APPLICATION_AUTHORIZATION_USER_WORKSPACE_ID', + }) + userWorkspace: Relation; + + @Index('IDX_APPLICATION_AUTHORIZATION_USER_WORKSPACE_ID') + @Column({ type: 'uuid' }) + userWorkspaceId: string; + + // Scopes as granted at the last exchange, which is what the user consented to + // and therefore what the revocation screen should show them. Null on a row + // reconstructed from a refresh token that predates this table: those tokens + // carry no scope claim, and what the application declares today is not + // evidence of what the user agreed to back then. + @Column({ type: 'text', array: true, nullable: true }) + scopes: string[] | null; + + // Null for the same reason, and on the same rows. + @Column({ type: 'timestamptz', nullable: true }) + lastAuthorizedAt: Date | null; + + // Touched on refresh. Refreshes happen at most once per access-token TTL, so + // this needs no write throttling of its own. + @Column({ type: 'timestamptz' }) + lastUsedAt: Date; + + // Kept forever once set: a revoked row is what tells a still-signed refresh + // token apart from one issued before authorizations were recorded. + @Column({ type: 'timestamptz', nullable: true }) + revokedAt: Date | null; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.module.ts new file mode 100644 index 0000000000..b3d20b4457 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity'; +import { ApplicationAuthorizationResolver } from 'src/engine/core-modules/application/application-authorization/application-authorization.resolver'; +import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([ApplicationAuthorizationEntity])], + providers: [ + ApplicationAuthorizationService, + ApplicationAuthorizationResolver, + ], + exports: [ApplicationAuthorizationService], +}) +export class ApplicationAuthorizationModule {} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.resolver.ts new file mode 100644 index 0000000000..e971ffb3ff --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-authorization/application-authorization.resolver.ts @@ -0,0 +1,72 @@ +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { Args, Mutation, Query } from '@nestjs/graphql'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { type ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity'; +import { ApplicationAuthorizationDTO } from 'src/engine/core-modules/application/application-authorization/dtos/application-authorization.dto'; +import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service'; +import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter'; +import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; + +// User-scoped on purpose: these are the applications this person authorized, +// which they may revoke for themselves without affecting anyone else. Removing +// an integration for the whole workspace is uninstalling it, an admin action +// that lives elsewhere. +@UsePipes(ResolverValidationPipe) +@UseFilters(AuthGraphqlApiExceptionFilter) +@MetadataResolver() +export class ApplicationAuthorizationResolver { + constructor( + private readonly applicationAuthorizationService: ApplicationAuthorizationService, + ) {} + + @Query(() => [ApplicationAuthorizationDTO]) + @UseGuards(UserAuthGuard, NoPermissionGuard) + async currentUserApplicationAuthorizations( + @AuthUser() user: AuthContextUser, + ): Promise { + const authorizations = + await this.applicationAuthorizationService.findActiveAuthorizationsForUser( + user.id, + ); + + return authorizations.map((authorization) => + this.toApplicationAuthorizationDTO(authorization), + ); + } + + @Mutation(() => Boolean) + @UseGuards(UserAuthGuard, NoPermissionGuard) + async revokeApplicationAuthorization( + @AuthUser() user: AuthContextUser, + @Args('applicationAuthorizationId', { type: () => UUIDScalarType }) + applicationAuthorizationId: string, + ): Promise { + return await this.applicationAuthorizationService.revokeAuthorizationById({ + authorizationId: applicationAuthorizationId, + userId: user.id, + }); + } + + private toApplicationAuthorizationDTO( + authorization: ApplicationAuthorizationEntity, + ): ApplicationAuthorizationDTO { + return { + id: authorization.id, + applicationId: authorization.applicationId, + workspaceId: authorization.workspaceId, + applicationName: authorization.application.name, + applicationUniversalIdentifier: + authorization.application.universalIdentifier, + scopes: authorization.scopes, + lastAuthorizedAt: authorization.lastAuthorizedAt, + lastUsedAt: authorization.lastUsedAt, + createdAt: authorization.createdAt, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-authorization/dtos/application-authorization.dto.ts b/packages/twenty-server/src/engine/core-modules/application/application-authorization/dtos/application-authorization.dto.ts new file mode 100644 index 0000000000..bc1ee98047 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-authorization/dtos/application-authorization.dto.ts @@ -0,0 +1,38 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('ApplicationAuthorization') +export class ApplicationAuthorizationDTO { + @Field(() => UUIDScalarType) + id: string; + + @Field(() => UUIDScalarType) + applicationId: string; + + @Field(() => UUIDScalarType) + workspaceId: string; + + @Field(() => String) + applicationName: string; + + // Two custom applications in a workspace can carry the same name, so the + // name alone cannot tell the user which authorization they are revoking. + @Field(() => String, { nullable: true }) + applicationUniversalIdentifier: string | null; + + // Null when the grant was reconstructed from a refresh token predating the + // authorization record, so the screen can say the original consent is + // unknown instead of inventing one. + @Field(() => [String], { nullable: true }) + scopes: string[] | null; + + @Field(() => Date, { nullable: true }) + lastAuthorizedAt: Date | null; + + @Field(() => Date) + lastUsedAt: Date; + + @Field(() => Date) + createdAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-authorization/services/application-authorization.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-authorization/services/application-authorization.service.ts new file mode 100644 index 0000000000..0bd0c29300 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-authorization/services/application-authorization.service.ts @@ -0,0 +1,161 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity'; + +@Injectable() +export class ApplicationAuthorizationService { + constructor( + @InjectRepository(ApplicationAuthorizationEntity) + private readonly applicationAuthorizationRepository: Repository, + ) {} + + // Re-authorizing an application the user previously revoked reinstates the + // same row: they have just consented again, so the revocation is spent. + async recordAuthorization({ + userId, + workspaceId, + userWorkspaceId, + applicationId, + scopes, + }: { + userId: string; + workspaceId: string; + userWorkspaceId: string; + applicationId: string; + scopes: string[]; + }): Promise { + const now = new Date(); + + await this.applicationAuthorizationRepository.upsert( + { + userId, + workspaceId, + userWorkspaceId, + applicationId, + scopes, + lastAuthorizedAt: now, + lastUsedAt: now, + revokedAt: null, + }, + { + conflictPaths: ['userId', 'applicationId'], + skipUpdateIfNoValuesChanged: false, + }, + ); + } + + // Stands in for the consent event that happened before this table existed. + // The refresh token proves the authorization took place but carries no scope + // claim and no timestamp for it, so both are left null instead of being + // guessed from what the application declares today. Insert-only, so it can + // never overwrite a row written by a real consent. + async backfillAuthorizationFromRefreshToken({ + userId, + workspaceId, + userWorkspaceId, + applicationId, + }: { + userId: string; + workspaceId: string; + userWorkspaceId: string; + applicationId: string; + }): Promise { + await this.applicationAuthorizationRepository + .createQueryBuilder() + .insert() + .values({ + userId, + workspaceId, + userWorkspaceId, + applicationId, + scopes: null, + lastAuthorizedAt: null, + lastUsedAt: new Date(), + revokedAt: null, + }) + .orIgnore() + .execute(); + } + + // Returns revoked rows too: the caller has to tell "never authorized" apart + // from "authorized then revoked", which are opposite answers. + async findByUserAndApplication({ + userId, + applicationId, + }: { + userId: string; + applicationId: string; + }): Promise { + return await this.applicationAuthorizationRepository.findOneBy({ + userId, + applicationId, + }); + } + + // Inner join, so an application that has been soft-deleted takes its + // authorizations off the list rather than surfacing them with nothing to + // name them. + async findActiveAuthorizationsForUser( + userId: string, + ): Promise { + return await this.applicationAuthorizationRepository + .createQueryBuilder('applicationAuthorization') + .innerJoinAndSelect('applicationAuthorization.application', 'application') + .where('applicationAuthorization.userId = :userId', { userId }) + .andWhere('applicationAuthorization.revokedAt IS NULL') + .orderBy('applicationAuthorization.lastUsedAt', 'DESC') + .getMany(); + } + + async touchLastUsedAt(authorizationId: string): Promise { + await this.applicationAuthorizationRepository.update( + { id: authorizationId }, + { lastUsedAt: new Date() }, + ); + } + + // Scoped by userId in the UPDATE itself rather than read-then-write, so one + // user can never revoke another user's authorization by guessing an id. + async revokeAuthorizationById({ + authorizationId, + userId, + }: { + authorizationId: string; + userId: string; + }): Promise { + return await this.revokeMatching({ + id: authorizationId, + userId, + }); + } + + async revokeAuthorizationForApplication({ + userId, + applicationId, + }: { + userId: string; + applicationId: string; + }): Promise { + return await this.revokeMatching({ userId, applicationId }); + } + + // Returns whether this call was the one that revoked it, so a repeated + // revocation reports false rather than moving revokedAt forward. The union + // rules out an empty criteria object, which would revoke every row. + private async revokeMatching( + criteria: + | { id: string; userId: string } + | { userId: string; applicationId: string }, + ): Promise { + const { affected } = await this.applicationAuthorizationRepository.update( + { ...criteria, revokedAt: IsNull() }, + { revokedAt: new Date() }, + ); + + return isDefined(affected) && affected > 0; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth/__tests__/oauth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth/__tests__/oauth.service.spec.ts new file mode 100644 index 0000000000..896fadd3fc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth/__tests__/oauth.service.spec.ts @@ -0,0 +1,489 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; +import { type ApplicationAuthorizationEntity } from 'src/engine/core-modules/application/application-authorization/application-authorization.entity'; +import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service'; +import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service'; +import { OAuthService } from 'src/engine/core-modules/application/application-oauth/oauth.service'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; + +describe('OAuthService', () => { + let service: OAuthService; + + const clientId = 'client-1'; + const clientSecret = 'client-secret'; + const userId = 'user-1'; + const workspaceId = 'workspace-1'; + const userWorkspaceId = 'user-workspace-1'; + const applicationId = 'application-1'; + const applicationRegistrationId = 'application-registration-1'; + const redirectUri = 'https://app.example.com/callback'; + const authorizationId = 'authorization-1'; + + const applicationRegistration = { + id: applicationRegistrationId, + name: 'Example', + universalIdentifier: 'example', + latestAvailableVersion: '1.0.0', + oAuthClientSecretHash: 'hash', + oAuthScopes: ['api', 'profile'], + }; + + const application = { + id: applicationId, + workspaceId, + applicationRegistrationId, + }; + + const refreshTokenPayload = { + sub: applicationId, + type: JwtTokenTypeEnum.APPLICATION_REFRESH, + applicationId, + workspaceId, + userWorkspaceId, + userId, + }; + + const appTokenRepository = { findOne: jest.fn(), update: jest.fn() }; + const applicationRepository = { find: jest.fn(), findOne: jest.fn() }; + const userWorkspaceRepository = { findOne: jest.fn() }; + + const applicationTokenService = { + generateApplicationTokenPair: jest.fn(), + generateApplicationAccessToken: jest.fn(), + validateApplicationRefreshToken: jest.fn(), + validateApplicationAccessToken: jest.fn(), + renewApplicationTokens: jest.fn(), + decodeToken: jest.fn(), + }; + + const applicationAuthorizationService = { + recordAuthorization: jest.fn(), + backfillAuthorizationFromRefreshToken: jest.fn(), + findByUserAndApplication: jest.fn(), + touchLastUsedAt: jest.fn(), + revokeAuthorizationForApplication: jest.fn(), + }; + + const applicationRegistrationService = { + findOneByClientId: jest.fn(), + verifyClientSecret: jest.fn(), + }; + + const buildAuthorization = ( + overrides: Partial = {}, + ): ApplicationAuthorizationEntity => + ({ + id: authorizationId, + userId, + workspaceId, + userWorkspaceId, + applicationId, + scopes: ['api'], + revokedAt: null, + ...overrides, + }) as ApplicationAuthorizationEntity; + + beforeEach(async () => { + jest.clearAllMocks(); + + applicationRegistrationService.findOneByClientId.mockResolvedValue( + applicationRegistration, + ); + applicationRegistrationService.verifyClientSecret.mockResolvedValue(true); + applicationRepository.findOne.mockResolvedValue(application); + userWorkspaceRepository.findOne.mockResolvedValue({ id: userWorkspaceId }); + applicationTokenService.generateApplicationTokenPair.mockResolvedValue({ + applicationAccessToken: { token: 'access-token', expiresAt: new Date() }, + applicationRefreshToken: { + token: 'refresh-token', + expiresAt: new Date(), + }, + }); + applicationTokenService.renewApplicationTokens.mockResolvedValue({ + applicationAccessToken: { token: 'access-token', expiresAt: new Date() }, + applicationRefreshToken: { + token: 'refresh-token', + expiresAt: new Date(), + }, + }); + applicationTokenService.validateApplicationRefreshToken.mockResolvedValue( + refreshTokenPayload, + ); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OAuthService, + { + provide: getRepositoryToken(AppTokenEntity), + useValue: appTokenRepository, + }, + { + provide: getRepositoryToken(ApplicationEntity), + useValue: applicationRepository, + }, + { + provide: getRepositoryToken(UserWorkspaceEntity), + useValue: userWorkspaceRepository, + }, + { + provide: ApplicationTokenService, + useValue: applicationTokenService, + }, + { + provide: ApplicationAuthorizationService, + useValue: applicationAuthorizationService, + }, + { + provide: ApplicationRegistrationService, + useValue: applicationRegistrationService, + }, + { provide: ApplicationService, useValue: { create: jest.fn() } }, + { + provide: ApplicationInstallService, + useValue: { installApplication: jest.fn() }, + }, + { + provide: TwentyConfigService, + useValue: { get: jest.fn(() => '30m') }, + }, + ], + }).compile(); + + service = module.get(OAuthService); + }); + + describe('exchangeAuthorizationCode', () => { + const exchange = () => + service.exchangeAuthorizationCode({ + authorizationCode: 'code', + clientId, + clientSecret, + redirectUri, + }); + + beforeEach(() => { + appTokenRepository.findOne.mockResolvedValue({ + id: 'app-token-1', + userId, + workspaceId, + revokedAt: null, + expiresAt: new Date(Date.now() + 60 * 1000), + context: { clientId, redirectUri, scope: 'api profile' }, + }); + }); + + it('should record the authorization with the granted scopes', async () => { + await exchange(); + + expect( + applicationAuthorizationService.recordAuthorization, + ).toHaveBeenCalledWith({ + userId, + workspaceId, + userWorkspaceId, + applicationId, + scopes: ['api', 'profile'], + }); + }); + + it('should record the authorization before issuing the token pair', async () => { + await exchange(); + + expect( + applicationAuthorizationService.recordAuthorization.mock + .invocationCallOrder[0], + ).toBeLessThan( + applicationTokenService.generateApplicationTokenPair.mock + .invocationCallOrder[0], + ); + }); + + it('should record no scopes when the authorization carried an empty scope', async () => { + appTokenRepository.findOne.mockResolvedValue({ + id: 'app-token-1', + userId, + workspaceId, + revokedAt: null, + expiresAt: new Date(Date.now() + 60 * 1000), + context: { clientId, redirectUri, scope: '' }, + }); + + await exchange(); + + expect( + applicationAuthorizationService.recordAuthorization, + ).toHaveBeenCalledWith(expect.objectContaining({ scopes: [] })); + }); + }); + + describe('refreshTokenGrant', () => { + const refresh = () => + service.refreshTokenGrant({ + refreshToken: 'refresh-token', + clientId, + clientSecret, + }); + + it('should refuse to renew once the user revoked the application', async () => { + applicationAuthorizationService.findByUserAndApplication.mockResolvedValue( + buildAuthorization({ revokedAt: new Date() }), + ); + + const result = await refresh(); + + expect(result).toEqual( + expect.objectContaining({ error: 'invalid_grant' }), + ); + expect( + applicationTokenService.renewApplicationTokens, + ).not.toHaveBeenCalled(); + }); + + it('should renew and touch the authorization when it is still live', async () => { + applicationAuthorizationService.findByUserAndApplication.mockResolvedValue( + buildAuthorization(), + ); + + const result = await refresh(); + + expect(result).toEqual( + expect.objectContaining({ access_token: 'access-token' }), + ); + expect( + applicationAuthorizationService.touchLastUsedAt, + ).toHaveBeenCalledWith(authorizationId); + }); + + it('should backfill the missing grant for a refresh token issued before authorizations were recorded', async () => { + applicationAuthorizationService.findByUserAndApplication.mockResolvedValue( + null, + ); + + const result = await refresh(); + + expect( + applicationAuthorizationService.backfillAuthorizationFromRefreshToken, + ).toHaveBeenCalledWith({ + userId, + workspaceId, + userWorkspaceId, + applicationId, + }); + expect(result).toEqual( + expect.objectContaining({ access_token: 'access-token' }), + ); + }); + + // The token carries no scope claim, so what the registration declares now + // is not evidence of what this user agreed to. + it('should not pass the registration scopes off as the backfilled consent', async () => { + applicationAuthorizationService.findByUserAndApplication.mockResolvedValue( + null, + ); + + await refresh(); + + expect( + applicationAuthorizationService.backfillAuthorizationFromRefreshToken, + ).toHaveBeenCalledWith(expect.not.objectContaining({ scopes: [] })); + expect( + applicationAuthorizationService.recordAuthorization, + ).not.toHaveBeenCalled(); + }); + + it('should refuse to renew when the user is no longer a member of the workspace', async () => { + applicationAuthorizationService.findByUserAndApplication.mockResolvedValue( + null, + ); + userWorkspaceRepository.findOne.mockResolvedValue(null); + + const result = await refresh(); + + expect(result).toEqual( + expect.objectContaining({ error: 'invalid_grant' }), + ); + expect( + applicationAuthorizationService.backfillAuthorizationFromRefreshToken, + ).not.toHaveBeenCalled(); + }); + + // Removing a member soft-deletes the membership, so the grant outlives it. + it('should refuse to renew an existing grant once the membership is gone', async () => { + applicationAuthorizationService.findByUserAndApplication.mockResolvedValue( + buildAuthorization(), + ); + userWorkspaceRepository.findOne.mockResolvedValue(null); + + const result = await refresh(); + + expect(result).toEqual( + expect.objectContaining({ error: 'invalid_grant' }), + ); + expect( + applicationTokenService.renewApplicationTokens, + ).not.toHaveBeenCalled(); + expect( + applicationAuthorizationService.touchLastUsedAt, + ).not.toHaveBeenCalled(); + }); + + it('should not look for an authorization when the token carries no user', async () => { + applicationTokenService.validateApplicationRefreshToken.mockResolvedValue( + { + ...refreshTokenPayload, + userId: undefined, + userWorkspaceId: undefined, + }, + ); + + const result = await refresh(); + + expect( + applicationAuthorizationService.findByUserAndApplication, + ).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ access_token: 'access-token' }), + ); + }); + }); + + describe('revokeToken', () => { + it('should revoke the underlying authorization', async () => { + const result = await service.revokeToken({ + token: 'refresh-token', + clientId, + clientSecret, + }); + + expect( + applicationAuthorizationService.revokeAuthorizationForApplication, + ).toHaveBeenCalledWith({ userId, applicationId }); + expect(result).toEqual({ success: true }); + }); + + // Without the row, the refresh path would find nothing and backfill a + // fresh active grant, so revoking a token that predates the record would + // silently do nothing. + it('should lay down the row before revoking so a pre-record token stays revoked', async () => { + await service.revokeToken({ + token: 'refresh-token', + clientId, + clientSecret, + }); + + expect( + applicationAuthorizationService.backfillAuthorizationFromRefreshToken + .mock.invocationCallOrder[0], + ).toBeLessThan( + applicationAuthorizationService.revokeAuthorizationForApplication.mock + .invocationCallOrder[0], + ); + }); + + it('should still revoke when the membership is gone', async () => { + userWorkspaceRepository.findOne.mockResolvedValue(null); + + await service.revokeToken({ + token: 'refresh-token', + clientId, + clientSecret, + }); + + expect( + applicationAuthorizationService.backfillAuthorizationFromRefreshToken, + ).not.toHaveBeenCalled(); + expect( + applicationAuthorizationService.revokeAuthorizationForApplication, + ).toHaveBeenCalledWith({ userId, applicationId }); + }); + + it('should stay a no-op for a token that carries no user', async () => { + applicationTokenService.validateApplicationRefreshToken.mockResolvedValue( + { + ...refreshTokenPayload, + userId: undefined, + }, + ); + + const result = await service.revokeToken({ + token: 'refresh-token', + clientId, + clientSecret, + }); + + expect( + applicationAuthorizationService.revokeAuthorizationForApplication, + ).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('should refuse to revoke an authorization the asking client was not issued', async () => { + applicationRepository.findOne.mockResolvedValue({ + ...application, + applicationRegistrationId: 'another-registration', + }); + + const result = await service.revokeToken({ + token: 'refresh-token', + clientId, + clientSecret, + }); + + expect( + applicationAuthorizationService.revokeAuthorizationForApplication, + ).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it('should stay a no-op when the caller did not identify itself as a client', async () => { + const result = await service.revokeToken({ token: 'refresh-token' }); + + expect( + applicationAuthorizationService.revokeAuthorizationForApplication, + ).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + }); + + describe('introspectToken', () => { + beforeEach(() => { + applicationTokenService.decodeToken.mockReturnValue(refreshTokenPayload); + }); + + it('should report a refresh token inactive once its authorization is revoked', async () => { + applicationAuthorizationService.findByUserAndApplication.mockResolvedValue( + buildAuthorization({ revokedAt: new Date() }), + ); + + expect( + await service.introspectToken({ + token: 'refresh-token', + clientId, + clientSecret, + }), + ).toEqual({ active: false }); + }); + + it('should report a refresh token active while its authorization stands', async () => { + applicationAuthorizationService.findByUserAndApplication.mockResolvedValue( + buildAuthorization(), + ); + + expect( + await service.introspectToken({ + token: 'refresh-token', + clientId, + clientSecret, + }), + ).toEqual(expect.objectContaining({ active: true })); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.module.ts index 63c9ad761b..33a72865f3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth/application-oauth.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; +import { ApplicationAuthorizationModule } from 'src/engine/core-modules/application/application-authorization/application-authorization.module'; import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; @@ -30,6 +31,7 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain- UserWorkspaceEntity, ]), ApplicationRegistrationModule, + ApplicationAuthorizationModule, ApplicationCoreModule, ApplicationInstallModule, TokenModule, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth/oauth.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth/oauth.service.ts index 148d483591..41b2fff5f3 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth/oauth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth/oauth.service.ts @@ -5,12 +5,13 @@ import crypto from 'crypto'; import ms from 'ms'; import { Repository } from 'typeorm'; -import { base64UrlEncode } from 'twenty-shared/utils'; +import { base64UrlEncode, isDefined } from 'twenty-shared/utils'; import { AppTokenEntity, AppTokenType, } from 'src/engine/core-modules/app-token/app-token.entity'; +import { ApplicationAuthorizationService } from 'src/engine/core-modules/application/application-authorization/services/application-authorization.service'; import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; @@ -34,6 +35,7 @@ export class OAuthService { @InjectRepository(UserWorkspaceEntity) private readonly userWorkspaceRepository: Repository, private readonly applicationTokenService: ApplicationTokenService, + private readonly applicationAuthorizationService: ApplicationAuthorizationService, private readonly applicationRegistrationService: ApplicationRegistrationService, private readonly applicationService: ApplicationService, private readonly applicationInstallService: ApplicationInstallService, @@ -219,6 +221,20 @@ export class OAuthService { ); } + const grantedScope = + authCodeToken.context?.scope ?? + applicationRegistration.oAuthScopes.join(' '); + + // Recorded before the tokens exist, so a refresh token is never handed out + // without the grant that makes it redeemable and revocable. + await this.applicationAuthorizationService.recordAuthorization({ + userId: authCodeToken.userId, + workspaceId: authCodeToken.workspaceId, + userWorkspaceId: userWorkspace.id, + applicationId: application.id, + scopes: this.parseScopes(grantedScope), + }); + const { applicationAccessToken, applicationRefreshToken } = await this.applicationTokenService.generateApplicationTokenPair({ workspaceId: authCodeToken.workspaceId, @@ -227,10 +243,6 @@ export class OAuthService { userWorkspaceId: userWorkspace.id, }); - const grantedScope = - authCodeToken.context?.scope ?? - applicationRegistration.oAuthScopes.join(' '); - this.logger.log( `Authorization code exchanged: client=${clientId} workspace=${authCodeToken.workspaceId} user=${authCodeToken.userId}`, ); @@ -360,6 +372,18 @@ export class OAuthService { ); } + if (isDefined(payload.userId)) { + const authorizationError = await this.consumeUserAuthorization({ + userId: payload.userId, + workspaceId: payload.workspaceId, + applicationId: application.id, + }); + + if (authorizationError) { + return authorizationError; + } + } + const { applicationAccessToken, applicationRefreshToken } = await this.applicationTokenService.renewApplicationTokens(payload); @@ -393,6 +417,8 @@ export class OAuthService { }): Promise<{ success: boolean }> { const { token, clientId, clientSecret } = params; + let applicationRegistration: ApplicationRegistrationEntity | undefined; + if (clientId) { const clientValidation = await this.validateClient(clientId); @@ -410,16 +436,35 @@ export class OAuthService { return { success: false }; } } + + applicationRegistration = clientValidation; } - // Since our tokens are stateless JWTs, we can't truly revoke them. - // We validate the token to log that revocation was requested. try { const payload = await this.applicationTokenService.validateApplicationRefreshToken( token, ); + // RFC 7009 §2.1: revoking a refresh token invalidates the authorization + // behind it, and only the client the token was issued to may ask for + // that. Access tokens stay stateless and live out their few minutes. + if (isDefined(applicationRegistration) && isDefined(payload.userId)) { + const application = await this.applicationRepository.findOne({ + where: { id: payload.applicationId }, + }); + + if ( + application?.applicationRegistrationId === applicationRegistration.id + ) { + await this.revokeUserAuthorization({ + userId: payload.userId, + workspaceId: payload.workspaceId, + applicationId: payload.applicationId, + }); + } + } + this.logger.log( `Token revocation requested for application ${payload.applicationId}`, ); @@ -477,6 +522,16 @@ export class OAuthService { return { active: false }; } + if ( + isDefined(decoded.userId) && + (await this.isAuthorizationRevoked({ + userId: decoded.userId, + applicationId: decoded.applicationId, + })) + ) { + return { active: false }; + } + return { active: true, sub: decoded.sub, @@ -552,6 +607,126 @@ export class OAuthService { return null; } + // Refresh tokens issued before authorizations were recorded have no row to + // check against. Rejecting them would sign every live integration out the + // moment this ships, so the first refresh backfills the grant that was always + // implied. A revoked authorization keeps its row, so this never resurrects + // access the user turned off. + private async consumeUserAuthorization({ + userId, + workspaceId, + applicationId, + }: { + userId: string; + workspaceId: string; + applicationId: string; + }): Promise { + const authorization = + await this.applicationAuthorizationService.findByUserAndApplication({ + userId, + applicationId, + }); + + if (isDefined(authorization?.revokedAt)) { + return this.errorResponse( + 'invalid_grant', + 'The user revoked this application access', + ); + } + + // Rechecked on every refresh, not just when backfilling: removing a member + // soft-deletes the membership, so an existing grant outlives it and nothing + // else in this path would notice. + const userWorkspace = await this.userWorkspaceRepository.findOne({ + where: { userId, workspaceId }, + }); + + if (!userWorkspace) { + return this.errorResponse( + 'invalid_grant', + 'User no longer has access to this workspace', + ); + } + + if (!isDefined(authorization)) { + await this.applicationAuthorizationService.backfillAuthorizationFromRefreshToken( + { + userId, + workspaceId, + userWorkspaceId: userWorkspace.id, + applicationId, + }, + ); + + return null; + } + + await this.applicationAuthorizationService.touchLastUsedAt( + authorization.id, + ); + + return null; + } + + // A token predating the authorization record has no row to mark revoked, and + // the refresh path would then happily backfill a fresh active one. Lay the + // row down first so the revocation has something to stick to. If the + // membership is gone the refresh already fails on that, so there is nothing + // worth recording. + private async revokeUserAuthorization({ + userId, + workspaceId, + applicationId, + }: { + userId: string; + workspaceId: string; + applicationId: string; + }): Promise { + const userWorkspace = await this.userWorkspaceRepository.findOne({ + where: { userId, workspaceId }, + }); + + if (isDefined(userWorkspace)) { + await this.applicationAuthorizationService.backfillAuthorizationFromRefreshToken( + { + userId, + workspaceId, + userWorkspaceId: userWorkspace.id, + applicationId, + }, + ); + } + + await this.applicationAuthorizationService.revokeAuthorizationForApplication( + { + userId, + applicationId, + }, + ); + } + + private async isAuthorizationRevoked({ + userId, + applicationId, + }: { + userId: string; + applicationId: string; + }): Promise { + const authorization = + await this.applicationAuthorizationService.findByUserAndApplication({ + userId, + applicationId, + }); + + return isDefined(authorization?.revokedAt); + } + + // RFC 6749 §3.3: scope is a space-delimited list, so an empty value has to + // collapse to no scopes rather than to one blank one. + private parseScopes(scope: string): string[] { + return scope.split(' ').filter((entry) => entry.length > 0); + } + private async findOrInstallApplication( applicationRegistration: ApplicationRegistrationEntity, workspaceId: string, diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index a48380094c..a58c64cc56 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -6,6 +6,7 @@ import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-que import { ActorModule } from 'src/engine/core-modules/actor/actor.module'; import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; +import { ApplicationAuthorizationModule } from 'src/engine/core-modules/application/application-authorization/application-authorization.module'; import { ApplicationDevelopmentModule } from 'src/engine/core-modules/application/application-development/application-development.module'; import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module'; import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module'; @@ -102,6 +103,7 @@ import { FileModule } from './file/file.module'; WellKnownModule, ApplicationRegistrationModule, ApplicationOAuthModule, + ApplicationAuthorizationModule, ApplicationModule, ApplicationInstallModule, ApplicationUpgradeModule, diff --git a/packages/twenty-server/test/integration/oauth/suites/oauth.integration-spec.ts b/packages/twenty-server/test/integration/oauth/suites/oauth.integration-spec.ts index f3aa4adb19..469a86733d 100644 --- a/packages/twenty-server/test/integration/oauth/suites/oauth.integration-spec.ts +++ b/packages/twenty-server/test/integration/oauth/suites/oauth.integration-spec.ts @@ -1,7 +1,9 @@ import crypto from 'crypto'; import bcrypt from 'bcrypt'; +import gql from 'graphql-tag'; import request from 'supertest'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; import { base64UrlEncode } from 'twenty-shared/utils'; import { type DataSource } from 'typeorm'; @@ -895,4 +897,286 @@ describe('OAuth (integration)', () => { .expect(401); }); }); + + describe('Per-user authorizations', () => { + const exchangeForTokens = async ( + scope = 'read write', + ): Promise<{ + accessToken: string; + refreshToken: string; + }> => { + const code = crypto.randomBytes(42).toString('hex'); + const hashedCode = crypto.createHash('sha256').update(code).digest('hex'); + + const tokenId = await insertAppToken(ds, { + value: hashedCode, + type: AppTokenType.AuthorizationCode, + userId: TEST_USER_ID, + workspaceId: TEST_WORKSPACE_ID, + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + context: { + redirectUri: 'https://example.com/callback', + clientId: testRegistration.oAuthClientId, + scope, + }, + }); + + createdEntityIds.tokens.push(tokenId); + + const res = await postToken({ + grant_type: 'authorization_code', + code, + client_id: testRegistration.oAuthClientId, + client_secret: testClientSecret, + redirect_uri: 'https://example.com/callback', + }).expect(200); + + return { + accessToken: res.body.access_token, + refreshToken: res.body.refresh_token, + }; + }; + + const findAuthorization = async () => { + const [authorization] = await ds.query( + `SELECT "scopes", "lastAuthorizedAt", "revokedAt" + FROM core."applicationAuthorization" + WHERE "userId" = $1 AND "applicationId" = $2`, + [TEST_USER_ID, testApplication.id], + ); + + return authorization; + }; + + const deleteAuthorization = () => + ds.query( + `DELETE FROM core."applicationAuthorization" + WHERE "userId" = $1 AND "applicationId" = $2`, + [TEST_USER_ID, testApplication.id], + ); + + const revokeRefreshToken = (refreshToken: string) => + request(baseUrl) + .post('/oauth/revoke') + .send({ + token: refreshToken, + client_id: testRegistration.oAuthClientId, + client_secret: testClientSecret, + }) + .expect(200); + + // 'read' alone, not the registration's full scope list, so the assertion + // fails if the granted scope is ignored in favour of the declared one. + it('should record the authorization with the scopes the user granted', async () => { + await exchangeForTokens('read'); + + const authorization = await findAuthorization(); + + expect(authorization).toBeDefined(); + expect(authorization.scopes).toEqual(['read']); + expect(authorization.revokedAt).toBeNull(); + }); + + it('should stop the refresh token being redeemed once the authorization is revoked', async () => { + const { refreshToken } = await exchangeForTokens(); + + await revokeRefreshToken(refreshToken); + + expect((await findAuthorization()).revokedAt).not.toBeNull(); + + const res = await postToken({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: testRegistration.oAuthClientId, + client_secret: testClientSecret, + }).expect(400); + + expect(res.body.error).toBe('invalid_grant'); + }); + + it('should let the user authorize again after revoking', async () => { + const { refreshToken: revokedRefreshToken } = await exchangeForTokens(); + + await revokeRefreshToken(revokedRefreshToken); + + const { refreshToken } = await exchangeForTokens(); + + expect((await findAuthorization()).revokedAt).toBeNull(); + + await postToken({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: testRegistration.oAuthClientId, + client_secret: testClientSecret, + }).expect(200); + }); + + // Deleting the row leaves a refresh token in the state every token minted + // before this table existed is in. + it('should backfill a refresh token that predates the authorization record without inventing a consent', async () => { + const { refreshToken } = await exchangeForTokens('read write'); + + await deleteAuthorization(); + + await postToken({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: testRegistration.oAuthClientId, + client_secret: testClientSecret, + }).expect(200); + + const authorization = await findAuthorization(); + + expect(authorization).toBeDefined(); + expect(authorization.scopes).toBeNull(); + expect(authorization.lastAuthorizedAt).toBeNull(); + expect(authorization.revokedAt).toBeNull(); + }); + + it('should keep a refresh token predating the authorization record revoked', async () => { + const { refreshToken } = await exchangeForTokens(); + + await deleteAuthorization(); + + await revokeRefreshToken(refreshToken); + + const res = await postToken({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: testRegistration.oAuthClientId, + client_secret: testClientSecret, + }).expect(400); + + expect(res.body.error).toBe('invalid_grant'); + }); + + const LIST_AUTHORIZATIONS_OPERATION = { + query: gql` + query CurrentUserApplicationAuthorizations { + currentUserApplicationAuthorizations { + id + applicationId + applicationName + scopes + } + } + `, + }; + + const revokeAuthorizationOperation = ( + applicationAuthorizationId: string, + ) => ({ + query: gql` + mutation RevokeApplicationAuthorization( + $applicationAuthorizationId: UUID! + ) { + revokeApplicationAuthorization( + applicationAuthorizationId: $applicationAuthorizationId + ) + } + `, + variables: { applicationAuthorizationId }, + }); + + const findListedAuthorization = async ( + token = APPLE_JANE_ADMIN_ACCESS_TOKEN, + ) => { + const res = await makeMetadataAPIRequest( + LIST_AUTHORIZATIONS_OPERATION, + token, + ); + + expect(res.body.errors).toBeUndefined(); + + return res.body.data.currentUserApplicationAuthorizations.find( + (authorization: { applicationId: string }) => + authorization.applicationId === testApplication.id, + ); + }; + + const revokeListedAuthorization = ( + applicationAuthorizationId: string, + token: string, + ) => + makeMetadataAPIRequest( + revokeAuthorizationOperation(applicationAuthorizationId), + token, + ); + + it('should list the authorization to the user who granted it', async () => { + await exchangeForTokens('read'); + + const authorization = await findListedAuthorization(); + + expect(authorization).toBeDefined(); + expect(authorization.applicationName).toBe(testRegistration.name); + expect(authorization.scopes).toEqual(['read']); + }); + + it('should stop the refresh token being redeemed when revoked from the list', async () => { + const { refreshToken } = await exchangeForTokens(); + + const { id } = await findListedAuthorization(); + + const revokeResponse = await revokeListedAuthorization( + id, + APPLE_JANE_ADMIN_ACCESS_TOKEN, + ); + + expect(revokeResponse.body.errors).toBeUndefined(); + expect(revokeResponse.body.data.revokeApplicationAuthorization).toBe( + true, + ); + + const res = await postToken({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: testRegistration.oAuthClientId, + client_secret: testClientSecret, + }).expect(400); + + expect(res.body.error).toBe('invalid_grant'); + expect(await findListedAuthorization()).toBeUndefined(); + }); + + it('should report a repeated revocation as a no-op', async () => { + await exchangeForTokens(); + + const { id } = await findListedAuthorization(); + + const firstRevoke = await revokeListedAuthorization( + id, + APPLE_JANE_ADMIN_ACCESS_TOKEN, + ); + + expect(firstRevoke.body.data.revokeApplicationAuthorization).toBe(true); + + const secondRevoke = await revokeListedAuthorization( + id, + APPLE_JANE_ADMIN_ACCESS_TOKEN, + ); + + expect(secondRevoke.body.data.revokeApplicationAuthorization).toBe(false); + }); + + it('should not let another user see or revoke the authorization', async () => { + await exchangeForTokens(); + + const { id } = await findListedAuthorization(); + + expect( + await findListedAuthorization(APPLE_JONY_MEMBER_ACCESS_TOKEN), + ).toBeUndefined(); + + const revokeResponse = await revokeListedAuthorization( + id, + APPLE_JONY_MEMBER_ACCESS_TOKEN, + ); + + expect(revokeResponse.body.data.revokeApplicationAuthorization).toBe( + false, + ); + expect((await findAuthorization()).revokedAt).toBeNull(); + }); + }); });