diff --git a/platform/wab/package.json b/platform/wab/package.json index bb7ee3be4..aa8476b32 100644 --- a/platform/wab/package.json +++ b/platform/wab/package.json @@ -354,6 +354,7 @@ "parse-data-url": "^2.0.0", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "passport-oauth2": "^1.8.0", "passport-oauth2-refresh": "^2.2.0", diff --git a/platform/wab/src/wab/server/AppServer.ts b/platform/wab/src/wab/server/AppServer.ts index 07ddb1f72..819624c01 100644 --- a/platform/wab/src/wab/server/AppServer.ts +++ b/platform/wab/src/wab/server/AppServer.ts @@ -31,6 +31,7 @@ import { initAnalyticsFactory, logger } from "@/wab/server/observability"; import { WabPromStats, trackPostgresPool } from "@/wab/server/promstats"; import { createRateLimiter } from "@/wab/server/rate-limit"; import * as adminRoutes from "@/wab/server/routes/admin"; +import * as provisioningRoutes from "@/wab/server/routes/provisioning"; import { getAnalyticsBillingInfoForTeam, getAnalyticsForProject, @@ -348,6 +349,7 @@ const isCsrfFreeRoute = (pathname: string, config: Config) => { pathname.includes("/api/v1/app-auth/userinfo") || pathname.includes("/api/v1/app-auth/token") || pathname.includes("/api/v1/copilot/ui/public") || + pathname.includes("/api/v1/provision") || (!config.production && (pathname === "/api/v1/projects/import" || pathname.includes("/api/v1/cmse/"))) @@ -1812,6 +1814,32 @@ export function addMainAppServerRoutes( withNext(uploadImage) ); + app.post( + "/api/v1/provision/users", + passport.authenticate('provision-jwt', { session: false }), + withNext(provisioningRoutes.provisionUser) + ); + app.post( + "/api/v1/provision/teams", + passport.authenticate('provision-jwt', { session: false }), + withNext(provisioningRoutes.provisionTeam) + ); + app.post( + "/api/v1/provision/workspaces", + passport.authenticate('provision-jwt', { session: false }), + withNext(provisioningRoutes.provisionWorkspace) + ); + app.post( + "/api/v1/provision/teams/:teamId/users", + passport.authenticate('provision-jwt', { session: false }), + withNext(provisioningRoutes.grantTeamUserPermissions) + ); + app.post( + "/api/v1/provision/workspaces/:workspaceId/users", + passport.authenticate('provision-jwt', { session: false }), + withNext(provisioningRoutes.grantWorkspaceUserPermissions) + ); + /** * CMS */ diff --git a/platform/wab/src/wab/server/auth/passport-cfg.ts b/platform/wab/src/wab/server/auth/passport-cfg.ts index 6093b1651..6728d96f4 100644 --- a/platform/wab/src/wab/server/auth/passport-cfg.ts +++ b/platform/wab/src/wab/server/auth/passport-cfg.ts @@ -39,6 +39,7 @@ import OAuth2Strategy from "passport-oauth2"; import refresh from "passport-oauth2-refresh"; import { getManager } from "typeorm"; import * as util from "util"; +import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt"; const LocalStrategy = passportLocal.Strategy; @@ -93,6 +94,34 @@ export async function setupPassport( ) ); + passport.use( + "provision-jwt", + new JwtStrategy( + { + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: + "-----BEGIN PUBLIC KEY-----\n" + + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvAnj5ENp4CVKULbrSPD36WZyeOZV\n" + + "+J0SMo6743PmcvvZkjmm6SXLmV0Dv8HqOXDA1c7YkuRFp7QueT5RZEz4oQ==\n" + + "-----END PUBLIC KEY-----", + algorithms: ["ES256"], + passReqToCallback: true, + }, + (req, jwt_payload, done) => { + asyncToCallback(done, async () => { + const mgr = superDbMgr(req); + const user = await mgr.tryGetUserByEmail(jwt_payload.sub); + + if (!user) { + return false; + } + + return user; + }); + } + ) + ); + /** * Sign in using Google. */ diff --git a/platform/wab/src/wab/server/db/DbMgr.ts b/platform/wab/src/wab/server/db/DbMgr.ts index 5072ad0f9..831d9f421 100644 --- a/platform/wab/src/wab/server/db/DbMgr.ts +++ b/platform/wab/src/wab/server/db/DbMgr.ts @@ -1196,6 +1196,11 @@ export class DbMgr implements MigrationDbMgr { return team; } + async tryGetTeamById(id: TeamId, includeDeleted = false) { + await this.checkTeamPerms(id, "viewer", "read", includeDeleted); + return await this._queryTeams({ id }, includeDeleted).getOne(); + } + async getTeamById(id: TeamId, includeDeleted = false) { await this.checkTeamPerms(id, "viewer", "read", includeDeleted); return ensureFound( @@ -10791,6 +10796,188 @@ export class DbMgr implements MigrationDbMgr { }, }); } + + async provisionUser({ + id, + email, + name, + }: { + id: UserId; + email: string; + name: string; + }) { + let user = await this.tryGetUserById(id); + if (user) { + mergeSane(user, this.stampUpdate(), { name: name }); + return await this.entMgr.save(user); + } + + user = this.users().create({ + ...this.stampNew({ id: id }), + email: email.toLowerCase(), + firstName: name, + // bcrypt: "", + bcrypt: bcrypt.hashSync("123123123", bcrypt.genSaltSync()), + needsIntroSplash: false, + needsSurvey: false, + waitingEmailVerification: false, + }); + + const personalTeam = this.teams().create({ + ...this.stampNew(), + name: "Personal team", + billingEmail: user.email, + personalTeamOwnerId: user.id, + }); + + const personalWorkspace = this.workspaces().create({ + ...this.stampNew(), + name: PERSONAL_WORKSPACE, + description: PERSONAL_WORKSPACE, + teamId: personalTeam.id, + }); + + const personalTeamPermission = this.permissions().create({ + ...this.stampNew(), + teamId: personalTeam.id, + userId: user.id, + accessLevel: "owner", + }); + + await this.entMgr.save(user); + await this.entMgr.save(personalTeam); + await this.entMgr.save(personalWorkspace); + await this.entMgr.save(personalTeamPermission); + + // This column is marked as select: false, but TypeORM's create() call + // doesn't respect it. Manually remove it here. + user.bcrypt = undefined; + return user; + } + + async provisionTeam({ id, name }: { id: TeamId; name: string }) { + let team = await this.tryGetTeamById(id); + if (team) { + mergeSane(team, this.stampUpdate(), { name: name }); + await this.entMgr.save(team); + return team; + } + + team = this.teams().create({ + ...this.stampNew({ id: id }), + name, + billingEmail: "", + }); + + await this.entMgr.save(team); + return team; + } + + async provisionWorkspace({ + id, + name, + teamId, + }: { + id: WorkspaceId; + name: string; + teamId: TeamId; + }) { + let workspace = await this._tryGetWorkspaceById(id, false); + if (workspace) { + mergeSane(workspace, this.stampUpdate(), { name: name }); + await this.entMgr.save(workspace); + return workspace; + } + + workspace = this.workspaces().create({ + ...this.stampNew(), + id, + name, + description: "", + team: { id: teamId }, + }); + + await this.entMgr.save(workspace); + return workspace; + } + + async grantTeamUserPermissions({ + teamId, + userId, + accessLevel, + }: { + teamId: TeamId; + userId: UserId; + accessLevel: string; + }) { + const team = await this.getTeamById(teamId); + const user = await this.getUserById(userId); + + const levelToGrant = ensureGrantableAccessLevel(accessLevel); + + let perm = await this.permissions().findOne({ + where: { + team, + user, + ...excludeDeleted(), + }, + }); + + if (perm) { + perm.accessLevel = levelToGrant; + await this.entMgr.save(perm); + return perm; + } + + perm = this.permissions().create({ + ...this.stampNew(), + team, + user, + accessLevel: levelToGrant, + }); + + await this.entMgr.save(perm); + return perm; + } + + async grantWorkspaceUserPermissions({ + workspaceId, + userId, + accessLevel, + }: { + workspaceId: WorkspaceId; + userId: UserId; + accessLevel: string; + }) { + const workspace = await this.getWorkspaceById(workspaceId); + const user = await this.getUserById(userId); + + const levelToGrant = ensureGrantableAccessLevel(accessLevel); + + let perm = await this.permissions().findOne({ + where: { + workspace, + user, + ...excludeDeleted(), + }, + }); + + if (perm) { + perm.accessLevel = levelToGrant; + await this.entMgr.save(perm); + return perm; + } + + perm = this.permissions().create({ + ...this.stampNew(), + workspace, + user, + accessLevel: levelToGrant, + }); + + await this.entMgr.save(perm); + return perm; + } } const Includes = (value: T): FindOperator => diff --git a/platform/wab/src/wab/server/routes/provisioning.ts b/platform/wab/src/wab/server/routes/provisioning.ts new file mode 100644 index 000000000..48a473741 --- /dev/null +++ b/platform/wab/src/wab/server/routes/provisioning.ts @@ -0,0 +1,51 @@ +import { mkApiTeam } from "@/wab/server/routes/teams"; +import { superDbMgr, userDbMgr } from "@/wab/server/routes/util"; +import { mkApiWorkspace } from "@/wab/server/routes/workspaces"; +import { + CreateTeamResponse, + CreateWorkspaceResponse, +} from "@/wab/shared/ApiSchema"; +import { ensureType } from "@/wab/shared/common"; +import { Request, Response } from "express-serve-static-core"; + +export async function provisionUser(req: Request, res: Response) { + const mgr = superDbMgr(req); + const user = await mgr.provisionUser(req.body); + res.json({ user }); +} + +export async function provisionTeam(req: Request, res: Response) { + const mgr = superDbMgr(req); + const team = await mgr.provisionTeam(req.body); + const apiTeam = mkApiTeam(team); + + res.json(ensureType({ team: apiTeam })); +} + +export async function provisionWorkspace(req: Request, res: Response) { + const mgr = superDbMgr(req); + const workspace = await mgr.provisionWorkspace(req.body); + const apiWorkspace = mkApiWorkspace(workspace); + + res.json(ensureType({ workspace: apiWorkspace })); +} + +export async function grantTeamUserPermissions(req: Request, res: Response) { + const mgr = superDbMgr(req); + const perm = await mgr.grantTeamUserPermissions({ + teamId: req.params.teamId, + ...req.body, + }); + + res.json({ perm }); +} + +export async function grantWorkspaceUserPermissions(req: Request, res: Response) { + const mgr = superDbMgr(req); + const perm = await mgr.grantWorkspaceUserPermissions({ + workspaceId: req.params.workspaceId, + ...req.body, + }); + + res.json({ perm }); +} \ No newline at end of file diff --git a/platform/wab/yarn.lock b/platform/wab/yarn.lock index f7cc05e30..604555400 100644 --- a/platform/wab/yarn.lock +++ b/platform/wab/yarn.lock @@ -15476,6 +15476,22 @@ jsonwebtoken@9.0.2: ms "^2.1.1" semver "^7.5.4" +jsonwebtoken@^9.0.0: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + jsprim@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d" @@ -15510,7 +15526,7 @@ jwa@^1.4.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" -jwa@^2.0.0: +jwa@^2.0.0, jwa@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== @@ -15547,6 +15563,14 @@ jws@^4.0.0: jwa "^2.0.0" safe-buffer "^5.0.1" +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -17395,6 +17419,14 @@ passport-google-oauth20@^2.0.0: dependencies: passport-oauth2 "1.x.x" +passport-jwt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/passport-jwt/-/passport-jwt-4.0.1.tgz#c443795eff322c38d173faa0a3c481479646ec3d" + integrity sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ== + dependencies: + jsonwebtoken "^9.0.0" + passport-strategy "^1.0.0" + passport-local@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/passport-local/-/passport-local-1.0.0.tgz#1fe63268c92e75606626437e3b906662c15ba6ee"