목차
문서 살펴보기 »
버그 신고
·
기능 요청
·
우리의 불화에 동참하세요
·
로드맵
·
엑스
·
알림 디렉토리
Novu는 인앱, 푸시, 이메일, SMS, 채팅 등 여러 채널을 통해 알림을 간편하게 보낼 수 있는 통합 API를 제공합니다. Novu를 사용하면 사용자 정의 워크플로우를 생성하고 각 채널에 대한 조건을 정의하여 알림이 가능한 가장 효과적인 방법으로 전달되도록 할 수 있습니다.
Novu는 SAML Single Sign-On(SSO) 기능을 제공하기 위한 기반을 마련한 사용자 관리 솔루션(인증 인프라)으로 Clerk를 구현했고, OAuth 공급자로 Google과 GitHub, 다단계 인증, 역할 기반 계정 제어(RBAC)를 구현했습니다. ) 등이 있습니다.
Adam이라는 개발자가 플랫폼 엔지니어 Denis의 확실한 지원을 받아 이를 구현했습니다.
대부분의 프로젝트와 마찬가지로 이 프로젝트도 백로그에서 시작되었습니다. 이전에는 수십 명의 고객이 이를 요청했고 우리 로드맵에서 해당 요청에 크게 찬성표를 보냈습니다.
알림 인프라 솔루션인 앱 및 아키텍처에는 사용자가 팀 구성원을 조직에 초대하고 각 역할의 액세스를 관리할 수 있도록 가입, 로그인, 세션 로그에서 사용자를 관리하는 작업이 포함됩니다.
모든 것은 우선순위에 관한 것입니다. Novu의 핵심 초점은 알림 및 메시지 관리와 관련된 모든 문제를 해결하여 사용자가 그럴 필요가 없도록 하는 것입니다. 따라서 우리는 알림 워크플로우 구축 및 관리를 위한 최고의 경험을 제공하고, 개발자가 자신의 시간을 보호할 수 있도록 지원하며, 제품 팀과 마케팅 팀 간의 협업을 원활하게 하는 데 사이클을 사용합니다.
효과적인 사용자 관리는 우리가 추구하는 '핵심 가치'에 해당되지 않습니다.
귀하가 엔지니어링 알림 부담을 우리 전문 지식에 전가하기를 기대하는 것과 마찬가지로, 우리는 효과적인 사용자 관리 엔지니어링 부담을 서기의 전문 지식에 넘겼습니다.
말할 필요도 없이 우리 팀은 맞춤화되고 잘 설계된 아키텍처를 기반으로 처음부터 사내에서 훌륭한 인증 및 권한 부여 인프라를 구축했습니다.
레벨이 높아질수록 알림 개발 경험을 완벽하게 만드는 데 더욱 집중합니다.
우리는 데이터베이스용 MongoDB, 결제용 Stripe 등 제품의 다른 측면에 대해 검증되고 테스트되었으며 선도적인 솔루션을 활용하기로 선택한 것처럼 개발자와 엔지니어가 바퀴를 재발명하지 않고 Novu가 알림을 처리하도록 해주기를 기대합니다. 사용자 관리 담당 서기. 우리는 이야기를 나눕니다.
사용자를 위한 안전하고 사용하기 쉬운 환경을 조성하세요.
이 프로젝트의 초기 초안을 개략적으로 설명할 때 간단하고 간단해 보일 수 있으며 어쩌면 주말 안에 완료될 수 있을 것 같은 인상을 줄 수도 있습니다.
초기 체크리스트 초안:
초기 초안이 변경되지 않았다면 프로젝트가 충분한 피드백과 의견을 받지 못한 것입니다. 당연히 목록도 길어졌습니다.
실제 체크리스트:
프로젝트 범위를 파악한 후 다음 단계는 연구를 수행하고 원하는 결과를 달성하는 데 필요한 리소스를 평가하는 것입니다.
이 프로세스에는 다음이 포함됩니다.
현재 상태와 제품의 각 레이어를 매우 명확하게 이해하세요.
그리고 더 많은 것
이전 사양 개요(내부에 남아 있고 방해되어야 하는 것)
하위 호환성
아마도 이전 동료로부터 유사한 프로젝트에 대한 참고 자료를 찾아보고 그들의 프로세스와 권장 사항을 배워보세요
오픈 소스 솔루션을 찾아보세요
공급업체(타사 솔루션)가 있는지 찾아 비교해 보세요.
그리고 더 많은 것
다른 블로그 게시물에서는 타사 솔루션(또는 제품)을 서비스/인프라로서 서비스 회사로서 평가하고 비교하는 방법을 살펴보겠습니다.
불충분한 연구나 부정확한 평가는 일반적으로 기술적 부채와 향후 리소스 손실(예: 추가 기능 추가 및 유지 관리 시 엔지니어링 시간 등)로 이어지며, 이로 인해 전체를 리팩토링해야 합니다. 따라서 각 옵션의 숨겨진 비용을 검색해 보세요.
숙련된 팀 리더는 각 옵션의 투자 수익(ROI)을 평가하는 방법을 알고 있어 비즈니스에 가장 적합한 결정을 내리는 데 도움이 됩니다.
그래서 우리는 Clerk를 만나게 되었습니다. 이들 솔루션은 대부분의 사용 사례를 포괄하며 비즈니스 관점에서 사용자 및 조직 계층을 관리하기 위해 솔루션을 구현하는 경우 ROI가 합리적입니다.
Novu 서비스에는 다음과 같은 다양한 마이크로 서비스와 측면이 포함되어 있습니다.
아래 다이어그램은 Clerk를 구현하기 전 Novu 사용자 및 조직의 인증 및 권한 부여에만 초점을 맞춘 Novu API 구조의 단순화된 버전을 보여줍니다.
우리는 MongoDB를 사용하여 Novu가 요구하는 모든 데이터, 즉 모든 사용자, 조직, 테넌트, 구독자, 주제 등 모든 것을 저장합니다.
Clerk은 사용자를 관리하기 위한 자체 데이터베이스를 보유하고 있기 때문에 데이터베이스 간의 마이그레이션 및 동기화를 매우 신중하고 정확하게 처리해야 했습니다.
우리가 확인해야 할 주요 사항 중 하나는 Novu를 사용할 때 UserSessionData 개체가 사용자 세션을 중단하지 않도록 변경되지 않는다는 것입니다. 호환성을 유지해야 합니다.
여기에서 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에서 필터링된 검색을 수행하여 관련 구성만 반환되도록 합니다.
Clark와 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 중국어 웹사이트의 기타 관련 기사를 참조하세요!