目錄
探索文件 »
報告錯誤
·
請求功能
·
加入我們的不和諧
·
路線圖
·
X
·
通知目錄
Novu 提供了統一的 API,可以輕鬆地透過多種管道發送通知,包括應用程式內、推播、電子郵件、簡訊和聊天。 借助 Novu,您可以建立自訂工作流程並為每個管道定義條件,確保以最有效的方式傳遞您的通知。
Novu 將Clerk 實作為使用者管理解決方案(驗證基礎架構),為提供SAML 單一登入(SSO) 功能、Google 和GitHub 作為OAuth 提供者、多重身分驗證、基於角色的帳戶控制(RBAC)奠定了基礎),等等。
一位名叫 Adam 的開發人員在平台工程師 Denis 的大力協助下實現了它。
像大多數專案一樣,這個專案是從積壓工作開始的。在此之前,已有數十名客戶提出要求,並在我們的路線圖中大力支持了該請求。
作為通知基礎設施解決方案,我們的應用程式和架構的一部分涉及透過註冊、登入和會話日誌來管理用戶,以使用戶能夠邀請團隊成員加入組織並管理每個角色的存取權限。
這一切都是關於優先事項的。 Novu 的核心重點是解決與通知和訊息管理相關的所有問題,這樣我們的用戶就不必這樣做。因此,我們將整個週期花在為建立和管理通知工作流程提供最佳體驗、幫助開發人員保護他們的時間以及平滑產品和行銷團隊之間的協作。
有效的使用者管理不屬於我們致力於的「核心價值」。
就像我們希望您將工程通知的負擔交給我們的專業知識一樣,我們也將工程有效用戶管理的負擔交給了文員的專業知識。
不用說,我們的團隊從第一天起就基於定制和精心設計的架構在內部構建了出色的身份驗證和授權基礎設施。
隨著我們的升級,我們更加專注於完善通知開發體驗。
我們希望開發人員和工程師避免重新發明輪子並讓Novu 處理通知,就像我們選擇在產品的其他方面利用經過驗證、測試和領先的解決方案一樣:用於資料庫的MongoDB、用於支付的Stripe,以及現在文員進行使用者管理。我們言行一致。
為我們的使用者創造安全且易於使用的體驗。
在概述該專案的初稿時,它可能顯得簡短而直接,甚至可能給人一種可以在周末完成的印象。
初步草稿清單:
請注意,如果初稿沒有更改,則該項目尚未收到足夠的回饋和輸入。自然,名單就更長了。
實際清單:
確定專案範圍後,下一步是進行研究並評估實現預期結果所需的資源。
這個過程包括:
對產品的當前狀態和每一層都有非常清晰的了解:
還有更多。
概述遷移規範(保留在內部且應被阻止的內容)
向後相容性
嘗試從以前的同事那裡找到類似項目的參考,並從他們的流程和建議中學習
嘗試尋找開源解決方案
尋找是否有任何供應商(第三方解決方案)並進行比較。
還有更多。
在另一篇部落格文章中,我們將探討如何評估和比較作為服務/基礎設施即服務公司的第三方解決方案(或產品)。
研究不足或評估不準確通常會導致技術債務和未來的資源損失,例如添加附加功能和維護時的工程時間,這需要重構整個事物。因此,尋找每個選項的隱藏成本。
經驗豐富的團隊領導知道如何評估每個選項的投資報酬率 (ROI),這有助於他們做出最佳的業務決策。
這正是我們最終得到 Clerk 的原因。他們的解決方案涵蓋了我們的大多數用例,從業務角度來看,實施它們來管理使用者和組織層的投資報酬率是有意義的。
Novu 服務包含許多微服務和麵向,例如:
下圖展示了 Novu 的 API 結構的簡化版本,僅關注在實現 Clerk 之前對 Novu 使用者和組織的身份驗證和授權。
我們使用 MongoDB 來儲存 Novu 所需的所有數據,每個使用者、組織、租戶、訂閱者、主題……簡而言之,一切。
因為Clerk有自己的資料庫來管理用戶,所以我們需要非常仔細且精確地處理資料庫之間的遷移和同步。
我們需要確保的主要事情之一是 UserSessionData 物件不會更改,以便在使用 Novu 時不會中斷使用者的會話。它應該保持兼容。
在這裡您可以看到 jwt.stratgy.ts 檔案範例:
//jwt.stratgy.ts import type http from 'http'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ApiAuthSchemeEnum, HttpRequestHeaderKeysEnum, UserSessionData } from '@novu/shared'; import { AuthService, Instrument } from '@novu/application-generic'; import { EnvironmentRepository } from '@novu/dal'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(private readonly authService: AuthService, private environmentRepository: EnvironmentRepository) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: process.env.JWT_SECRET, passReqToCallback: true, }); } @Instrument() async validate(req: http.IncomingMessage, session: UserSessionData) { // Set the scheme to Bearer, meaning the user is authenticated via a JWT coming from Dashboard session.scheme = ApiAuthSchemeEnum.BEARER; const user = await this.authService.validateUser(session); if (!user) { throw new UnauthorizedException(); } await this.resolveEnvironmentId(req, session); return session; } @Instrument() async resolveEnvironmentId(req: http.IncomingMessage, session: UserSessionData) { // Fetch the environmentId from the request header const environmentIdFromHeader = (req.headers[HttpRequestHeaderKeysEnum.NOVU_ENVIRONMENT_ID.toLowerCase()] as string) || ''; /* * Ensure backwards compatibility with existing JWTs that contain environmentId * or cached SPA versions of Dashboard as there is no guarantee all current users * will have environmentId in localStorage instantly after the deployment. */ const environmentIdFromLegacyAuthToken = session.environmentId; let currentEnvironmentId = ''; if (environmentIdFromLegacyAuthToken) { currentEnvironmentId = environmentIdFromLegacyAuthToken; } else { const environments = await this.environmentRepository.findOrganizationEnvironments(session.organizationId); const environmentIds = environments.map((env) => env._id); const developmentEnvironmentId = environments.find((env) => env.name === 'Development')?._id || ''; currentEnvironmentId = developmentEnvironmentId; if (environmentIds.includes(environmentIdFromHeader)) { currentEnvironmentId = environmentIdFromHeader; } } session.environmentId = currentEnvironmentId; } }
為了保持與應用程式其餘部分的兼容性,我們需要將 JWT 有效負載從 Clerk 轉換為先前存在的 JWT 格式。
我們就是這樣做的:
async validate(payload: ClerkJwtPayload): Promise<IJwtClaims> { const jwtClaims: IJwtClaims = { // first time its clerk_id, after sync its novu internal id _id: payload.externalId || payload._id, firstName: payload.firstName, lastName: payload.lastName, email: payload.email, profilePicture: payload.profilePicture, // first time its clerk id, after sync its novu internal id organizationId: payload.externalOrgId || payload.org_id, environmentId: payload.environmentId, roles: payload.org_role ? [payload.org_role.replace('org:', '')] : [], exp: payload.exp, }; return jwtClaims; }
在這裡您可以看到 clerk.strategy.ts 檔案範例:
import type http from 'http'; import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { passportJwtSecret } from 'jwks-rsa'; import { ApiAuthSchemeEnum, ClerkJwtPayload, HttpRequestHeaderKeysEnum, PassportStrategyEnum, UserSessionData, } from '@novu/shared'; import { EnvironmentRepository, EnvironmentEntity } from '@novu/dal'; import { LinkEntitiesService } from '../services/link-entities.service'; @Injectable() export class ClerkStrategy extends PassportStrategy(Strategy, PassportStrategyEnum.JWT_CLERK) { constructor(private environmentRepository: EnvironmentRepository, private linkEntitiesService: LinkEntitiesService) { super({ // ...configuration details }); } async validate(req: http.IncomingMessage, payload: ClerkJwtPayload) { const { internalUserId, internalOrgId } = await this.linkEntitiesService.linkInternalExternalEntities(req, payload); const session: UserSessionData = { _id: internalUserId, firstName: payload.firstName, lastName: payload.lastName, email: payload.email, profilePicture: payload.profilePicture, organizationId: internalOrgId, roles: payload.org_role ? [payload.org_role.replace('org:', '')] : [], exp: payload.exp, iss: payload.iss, scheme: ApiAuthSchemeEnum.BEARER, environmentId: undefined, }; await this.resolveEnvironmentId(req, session); return session; } // Other functions... }
雖然目標是理想情況下僅使用Clerk 來創建和檢索用戶、組織等,但不幸的是,由於需要以高性能的方式存儲和查詢有關用戶和組織的一些元數據,因此這並不完全可能。
以下是 Novu 組織儲存庫中的方法範例:
async findPartnerConfigurationDetails(organizationId: string, userId: string, configurationId: string) { const organizationIds = await this.getUsersMembersOrganizationIds(userId); return await this.find( { _id: { $in: organizationIds }, 'partnerConfigurations.configurationId': configurationId, }, { 'partnerConfigurations.$': 1 } ); }
此方法使用各種 MongoDB 特定結構來過濾文件 - 使用 Clerk 無法以高效能方式重現,因為這不是用於此類查詢的資料庫。
我們能做的就是將這些關於組織的元資料儲存在 MongoDB 組織集合中,並使用 externalId 將集合與 Clerk 資料庫連結/同步。
現在我們可以根據需要結合 Clerk 和 MongoDB 來查詢元資料。
async findPartnerConfigurationDetails( organizationId: string, userId: string, configurationId: string ): Promise<OrganizationEntity[]> { const clerkOrganizations = await this.getUsersMembersOrganizations(userId); return await this.communityOrganizationRepository.find( { _id: { $in: clerkOrganizations.map((org) => org.id) }, 'partnerConfigurations.configurationId': configurationId, }, { 'partnerConfigurations.$': 1 } ); } private async getUsersMembersOrganizations(userId: string): Promise<Organization[]> { const userOrgMemberships = await this.clerkClient.users.getOrganizationMembershipList({ userId, }); return userOrgMemberships.data.map((membership) => membership.organization); }
透過呼叫 getUsersMembersOrganizations,findPartnerConfigurationDetails 取得必要的組織數據,以在communityOrganizationRepository 上執行過濾搜索,確保僅傳回相關配置。
我們只需要在 Clerk 和 Novu 之間同步使用者和組織,組織成員不需要同步。
資料庫 ID 同步有兩種方法:
Here is the flow we had in mind:
Note
In the application, we always expect Novu’s internal id on input and we always return internal id on output - its important for the application to work as is without major changes to the rest of the code.
API expects internal _id everywhere and it needs to be MongoDB ObjectID type, because it parses this user id back to ObjectID e.g. when creating new environment or any other entity which needs reference to user.
The same logic applies to organizations; just the endpoint is different.
Users
For the users, we store everything in Clerk. All the properties are mostly just simple key/value pairs and we don’t need any advanced filtering on them, therefore they can be retrieved and updated directly in Clerk.
In internal MongoDB, we store just the user internal and external ids.
The original Novu user properties are stored in Clerk’s publicMetadata :
export type UserPublicMetadata = { profilePicture?: string | null; showOnBoardingTour?: number; };
There are also many other attributes coming from Clerk which can be set on the user.
Organizations
For the organizations, we store everything in Clerk except for apiServiceLevel, partnerConfigurations, and branding since they are “native” to Clerk and we update those attributes directly there via frontend components and so we don’t need to sync with our internal DB after we change organization name or logo via Clerk component.
The goal here was to replace the community (open source) implementation with Clerk while being minimally invasive to the application and to keep the Clerk implementation in a separate package.
This means we need to keep the changed providers (OrganizationRepository, AuthService…) on the same place with the same name so we don’t break the imports all over the place, but we need to change their body to be different based on feature flag.
The other option would be to change all of these providers in the 100+ of files and then import the EE(enterprise edition) package everywhere, which is probably not a good idea.
This turned out to be quite challenging due to the fact that users, organization and members are relatively deeply integrated to the application itself, referenced in a lot of places and they’re also tied to MongoDB specifics such as ObjectID or queries (create, update, findOne …).
The idea is to provide different implementation using NestJS dynamic custom providers where we are able to inject different class/service on compile time based on the enterprise feature flag.
This is the most promising solution we found while keeping the rest of the app mostly untouched, there are some drawbacks explained later.
We have two implementations of AuthService - community and enterprise one (in private package), we inject one of those as AUTH_SERVICE provider.
We need to however have a common interface for both IAuthService
Since we also need to change the AuthModule, we initialize two different modules based on the feature flag like this:
function getModuleConfig(): ModuleMetadata { if (process.env.NOVU_ENTERPRISE === 'true') { return getEEModuleConfig(); } else { return getCommunityAuthModuleConfig(); } } @Global() @Module(getModuleConfig()) export class AuthModule { public configure(consumer: MiddlewareConsumer) { if (process.env.NOVU_ENTERPRISE !== 'true') { configure(consumer); } } }
The reason why the EEModule can be a standalone module in the @novu/ee-auth package which we would just import instead of the original AuthModule and instead we are initializing one module conditionally inside API, is that we are reusing some original providers in the EE one - e.g. ApiKeyStrategy , RolesGuard, EnvironmentGuard etc which resides directly in API.
We would need to import them in the @novu/ee-auth package which would require to export these things somewhere (probably in some shared package) and it introduces other issues like circular deps etc - it can be however refactored later.
Same logic applies for the repositories. No module is being initialized here, they’re just directly injected to the original repository classes.
The controllers are being conditionally imported from inside @novu/api . The reason for that is the same as in the auth module, there are too many imports that the controllers uses, that we would either need to move to @novu/ee-auth or move them to a separate shared package - which would then trigger a much bigger change to the other unrelated parts of the app, which would increase the scope of this change.
function getControllers() { if (process.env.NOVU_ENTERPRISE === 'true') { return [EEOrganizationController]; } return [OrganizationController]; } @Module({ controllers: [...getControllers()], }) export class OrganizationModule implements NestModule { ... }
The main issue here is the need for common interface for both of the classes - community and enterprise. You want to remain compatible in both community and enterprise versions, so when there is a this.organizationService.getOrganizations() method being called in 50 places in the app - you need an enterprise equivalent with the same name otherwise you need to change 50 places to call something else.
This results in not-so-strict typing and methods without implementation
We need to have a common interface for both, however the community one relies on MongoDB methods and needs different method arguments as the enterprise one which causes a use of any to forcefully fit both classes etc.
In some cases we don’t need the method at all, so we need to throw Not Implemented .
We modified the endpoints as follows:
Lead Engineer: Adam Chmara
Platform Team Lead: Denis Kralj
Our implementation approach comes to the fact that we offloaded the Users, Organizations and Members management to Clerk.
The data property injection to Novu’s Controllers (endpoints) layer, Business layer and data layer happens based on “Enterprise” feature flag validation.
We are leveraging pre-built Clerk components on the frontend and reducing the need to build and maintain our own custom implementation on the backend.
You can also observe below the diagram of the current state after implementing Clerk.
當我們決定實施 Clerk 進行使用者管理時,我們也選擇了擴展 Clerk 未來將支援和提供的功能和特性的長期利益。
以下是我們在不久的將來可能考慮支持的一些例子:
為 Novu 提供這種程度的詳細靈活性,可能超出了範圍,甚至由於實施的潛在複雜性而被刮掉。
使用者冒充
我們的客戶成功或支援團隊可以使用它來解決問題、提供支援或從模擬使用者的角度測試功能,而無需知道他們的密碼或其他身份驗證詳細資訊。
簡而言之,鑑於現在身份驗證基礎設施已被佔用,我們將能夠輕鬆改善 Novu 用戶的體驗。
如果您想建議涉及 AuthN(或任何其他)的其他功能,請訪問我們的路線圖來審核和投票請求,或提交您的想法。
喜歡您所讀的內容嗎?點擊關注以獲取更多更新,並在下面發表評論。我❤️想聽聽你的?
以上是將使用者管理從內部轉移到產品:我們為什麼這樣做以及我們學到了什麼的詳細內容。更多資訊請關注PHP中文網其他相關文章!