Merge pull request #429 from padloc/feature/scim-v1

Implement provisioning and syncing org users and groups via [SCIM](http://www.simplecloud.info/)
This commit is contained in:
Martin Kleinschrodt 2022-05-17 08:21:50 +02:00 committed by GitHub
commit 62e58f3040
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1855 additions and 268 deletions

View File

@ -31,7 +31,7 @@ describe("Server", () => {
message: "",
},
kind: "response",
version: "3.1.0",
version: "4.0.0",
})
);
});
@ -46,7 +46,7 @@ describe("Server", () => {
device: {},
auth: {},
kind: "request",
version: "3.1.0",
version: "4.0.0",
}),
}).then((response) => {
expect(response.status).to.eq(200);
@ -55,7 +55,7 @@ describe("Server", () => {
result: null,
error: { code: "invalid_session", message: "" },
kind: "response",
version: "3.1.0",
version: "4.0.0",
})
);
});

View File

@ -251,29 +251,25 @@
# =============================================================================
# PROVISIONING
#
# Supported backends: simple (default), stripe
# Supported backends: simple (default), stripe, directory
# =============================================================================
# -----------------------------------------------------------------------------
# SIMPLE PROVISIONING
# BASIC PROVISIONING
#
# Manage provisioning through a simple api
# Basic provisioner which allows managing provisioning users and orgs through
# database entries.
# -----------------------------------------------------------------------------
# PL_PROVISIONING_BACKEND=simple
# PL_PROVISIONING_SIMPLE_PORT=4000
# PL_PROVISIONING_SIMPLE_API_KEY=""
# PL_PROVISIONING_SIMPLE_DEFAULT_STATUS=active
# PL_PROVISIONING_SIMPLE_DEFAULT_STATUS_LABEL=""
# PL_PROVISIONING_SIMPLE_DEFAULT_STATUS_MESSAGE=""
# PL_PROVISIONING_SIMPLE_DEFAULT_ACTION_URL=""
# PL_PROVISIONING_SIMPLE_DEFAULT_ACTION_LABEL=""
# PL_PROVISIONING_SIMPLE_DEFAULT_DISABLE_FEATURES=""
# PL_PROVISIONING_SIMPLE_DEFAULT_QUOTA_ORGS=3
# PL_PROVISIONING_SIMPLE_DEFAULT_QUOTA_VAULTS=3
# PL_PROVISIONING_BACKEND=basic
# PL_PROVISIONING_BASIC_DEFAULT_STATUS=active
# PL_PROVISIONING_BASIC_DEFAULT_STATUS_LABEL=""
# PL_PROVISIONING_BASIC_DEFAULT_STATUS_MESSAGE=""
# PL_PROVISIONING_BASIC_DEFAULT_ACTION_URL=""
# PL_PROVISIONING_BASIC_DEFAULT_ACTION_LABEL=""
# -----------------------------------------------------------------------------
# SIMPLE PROVISIONING
# STRIPE PROVISIONING
#
# Manage provisioning via stripe
# -----------------------------------------------------------------------------
@ -283,6 +279,15 @@
# PL_PROVISIONING_STRIPE_PUBLIC_KEY=[required]
# PL_PROVISIONING_STRIPE_WEBHOOK_PORT=[required]
# -----------------------------------------------------------------------------
# DIRECTORY PROVISIONING
#
# Manage provisioning via directory (only SCIM supported for now)
# -----------------------------------------------------------------------------
# PL_PROVISIONING_BACKEND=directory
# PL_DIRECTORY_PROVIDERS=scim
# PL_DIRECTORY_SCIM_PORT=[required]
# =============================================================================
# SERVER
@ -292,9 +297,10 @@
# PL_SERVER_CLIENT_URL=http://localhost:8080
# PL_SERVER_REPORT_ERRORS=webmaster@example.com
# PL_MAX_REQUEST_AGE=3600000
# PL_VERIFY_EMAIL_ON_SIGNUP=true
# PL_DEFAULT_AUTH_TYPES=email
# PL_SERVER_MAX_REQUEST_AGE=3600000
# PL_SERVER_VERIFY_EMAIL_ON_SIGNUP=true
# PL_SERVER_DEFAULT_AUTH_TYPES=email
# PL_SERVER_DISABLE_SIGNUP=false
# =============================================================================

View File

@ -0,0 +1,19 @@
# SCIM ( with Active Directory ) Example
These are simple instructions to setup SCIM provisioning with Active Directory
(Azure Active Directory is used below, but any other setup should be similar).
1. Make sure your server has SCIM support enabled (i.e.
`PL_PROVISIONING_BACKEND=directory` and `PL_DIRECTORY_PROVIDERS=scim`).
2. Go to your organization's settings in Padloc and enable Directory Sync. Take
note of the `Tenant URL` and `Secret Token` values, as you'll need them in
step 4.
3. In your Active Directory, create a new Enterprise application (you can name
id "Padloc", for example) and choose Automatic provisioning.
4. Enter the proper `Tenant URL` (you) and `Secret Token` values you got from
step 2.
5. Test the connection, it should pass.
That is it. You can now optionally try "Provision on demand" to manually
provision some user, or simply "Start provisioning" to get it automatically
synchronizing values every X minutes, depending on your setup.

View File

@ -54,15 +54,17 @@ export class CreateOrgDialog extends Dialog<void, Org> {
}
const org = (this._org = app.getOrg(this._org.id)!);
const provisioning = app.getOrgProvisioning(org);
// const provisioning = app.getOrgProvisioning(org);
// Create first vault and group
if (provisioning?.quota.groups !== 0) {
const everyone = await app.createGroup(org, "Everyone", [{ id: app.account!.id }], []);
await app.createVault("Main", org, [], [{ name: everyone.name, readonly: false }]);
} else {
await app.createVault("Main", org, [{ id: app.account!.id, readonly: false }]);
}
// // Create first vault and group
// if (provisioning?.quota.groups !== 0) {
// const everyone = await app.createGroup(org, "Everyone", [{ email: app.account!.id }], []);
// await app.createVault("Main", org, [], [{ name: everyone.name, readonly: false }]);
// } else {
// await app.createVault("Main", org, [
// { email: app.account!.email, accountId: app.account!.id, readonly: false },
// ]);
// }
this._submitButton.success();
this.done(org);

View File

@ -16,6 +16,7 @@ import { Input } from "./input";
import "./toggle";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { AccountID } from "@padloc/core/src/account";
@customElement("pl-group-view")
export class GroupView extends Routing(StateMixin(LitElement)) {
@ -48,11 +49,12 @@ export class GroupView extends Routing(StateMixin(LitElement)) {
private _vaults: { id: string; readonly: boolean }[] = [];
@state()
private _members: { id: string }[] = [];
private _members: { email: string; accountId?: AccountID }[] = [];
private get _availableMembers() {
return (
(this._org && this._org.members.filter((member) => !this._members.some((m) => m.id === member.id))) || []
(this._org && this._org.members.filter((member) => !this._members.some((m) => m.email === member.email))) ||
[]
);
}
@ -96,7 +98,7 @@ export class GroupView extends Routing(StateMixin(LitElement)) {
const currentMembers = this._getCurrentMembers();
const hasMembersChanged =
this._members.length !== currentMembers.length ||
this._members.some((member) => !currentMembers.some((m) => m.id === member.id));
this._members.some((member) => !currentMembers.some((m) => m.email === member.email));
return hasNameChanged || hasVaultsChanged || hasMembersChanged;
}
@ -113,8 +115,8 @@ export class GroupView extends Routing(StateMixin(LitElement)) {
}
}
private _addMember({ id }: OrgMember) {
this._members.push({ id });
private _addMember({ email, accountId }: OrgMember) {
this._members.push({ email, accountId });
this.requestUpdate();
}
@ -124,7 +126,7 @@ export class GroupView extends Routing(StateMixin(LitElement)) {
}
private _removeMember(member: OrgMember) {
this._members = this._members.filter((m) => m.id !== member.id);
this._members = this._members.filter((m) => m.email !== member.email);
}
private _removeVault(vault: { id: string }) {

View File

@ -269,7 +269,7 @@ export class ItemView extends Routing(StateMixin(LitElement)) {
const { updated, updatedBy } = this._item!;
const vault = this._vault!;
const org = vault.org && app.getOrg(vault.org.id);
const updatedByMember = org && org.getMember({ id: updatedBy });
const updatedByMember = org && org.getMember({ accountId: updatedBy });
const attachments = this._item!.attachments || [];
const isFavorite = app.account!.favorites.has(this.itemId);

View File

@ -1,4 +1,4 @@
import { Org, OrgMember, OrgRole } from "@padloc/core/src/org";
import { Org, OrgMember, OrgMemberStatus, OrgRole } from "@padloc/core/src/org";
import { translate as $l } from "@padloc/locale/src/translate";
import { shared } from "../styles";
import "./randomart";
@ -32,7 +32,8 @@ export class MemberItem extends LitElement {
render() {
const isAdmin = this.member.role === OrgRole.Admin;
const isOwner = this.member.role === OrgRole.Owner;
const isSuspended = this.member.role === OrgRole.Suspended;
const isProvisioned = this.member.status === OrgMemberStatus.Provisioned;
const isSuspended = this.org?.isSuspended(this.member);
const groups = this.org?.getGroupsForMember(this.member) || [];
return html`
@ -74,6 +75,9 @@ export class MemberItem extends LitElement {
<pl-icon class="inline" icon="admin"></pl-icon> ${$l("Admin")}
</div>
`
: ""}
${isProvisioned
? html` <div class="tiny tag subtle">${$l("Provisioned")}</div> `
: isSuspended
? html` <div class="tiny tag warning">${$l("Suspended")}</div> `
: ""}

View File

@ -1,4 +1,4 @@
import { OrgRole, Group } from "@padloc/core/src/org";
import { OrgRole, Group, OrgMemberStatus } from "@padloc/core/src/org";
import { translate as $l } from "@padloc/locale/src/translate";
import { shared } from "../styles";
import { app } from "../globals";
@ -31,7 +31,7 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
}
private get _member() {
return this._org && this._org.getMember({ id: this.memberId });
return this._org && this._org.getMember({ accountId: this.memberId });
}
@query("#saveButton")
@ -272,7 +272,7 @@ export class MemberView extends Routing(StateMixin(LitElement)) {
this._saveButton.start();
try {
await app.updateMember(this._org!, this._member!, { role: OrgRole.Suspended });
await app.updateMember(this._org!, this._member!, { status: OrgMemberStatus.Suspended });
this._saveButton.success();
this.requestUpdate();
} catch (e) {

View File

@ -11,6 +11,10 @@ import "./icon";
import "./org-nav";
import { customElement, property, query } from "lit/decorators.js";
import { html, LitElement } from "lit";
import "./drawer";
import { ToggleButton } from "./toggle-button";
import { setClipboard } from "../lib/clipboard";
import { live } from "lit/directives/live.js";
@customElement("pl-org-settings")
export class OrgSettingsView extends Routing(StateMixin(LitElement)) {
@ -116,6 +120,50 @@ export class OrgSettingsView extends Routing(StateMixin(LitElement)) {
}
}
private async _enableDirectorySync() {
const confirmed = await confirm(
$l(
"Do you want to enable Directory Sync via SCIM for this organization? You will be given a unique URL to provide to your Active Directory or LDAP server for synchronizing and provisioning members."
),
$l("Confirm")
);
if (!confirmed) {
return;
}
// TODO: In the future, ask if only groups/members should be synchronized
await app.updateOrg(this._org!.id, async (org) => {
org.directory.syncProvider = "scim";
org.directory.syncGroups = true;
org.directory.syncMembers = true;
});
}
private async _disableDirectorySync() {
const confirmed = await confirm(
$l(
"Do you want to disable Directory Sync? Your members will no longer be automatically synchronized and provisioned."
),
$l("Confirm")
);
if (!confirmed) {
this.requestUpdate();
return;
}
await app.updateOrg(this._org!.id, async (org) => {
org.directory.syncProvider = "none";
});
}
private async _toggleDirectorySync(e: Event) {
const toggle = e.target as ToggleButton;
toggle.active ? await this._enableDirectorySync() : await this._disableDirectorySync();
}
static styles = [shared];
render() {
@ -135,6 +183,8 @@ export class OrgSettingsView extends Routing(StateMixin(LitElement)) {
<pl-scroller class="stretch">
<div class="vertical center-aligning padded layout">
<div class="vertical spacing layout fill-horizontally max-width-30em">
${this._renderDirectorySettings()}
<section class="margined box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Security")}</h2>
@ -169,4 +219,57 @@ export class OrgSettingsView extends Routing(StateMixin(LitElement)) {
</div>
`;
}
private _renderDirectorySettings() {
const org = this._org!;
const syncEnabled = org.directory.syncProvider !== "none";
const scimUrl = (syncEnabled && org.directory.scim?.url) || "";
const scimSecretToken = (syncEnabled && org.directory.scim?.secretToken) || "";
return html`
<div class="vertical spacing layout fill-horizontally">
<section class="margined box">
<h2 class="padded uppercase bg-dark border-bottom semibold">${$l("Directory Sync")}</h2>
<div>
<pl-toggle-button
class="transparent"
.active=${live(syncEnabled)}
.label=${$l("Enable Directory Sync")}
reverse
@change=${this._toggleDirectorySync}
>
</pl-toggle-button>
</div>
<pl-drawer .collapsed=${!syncEnabled}>
<div
class="padded border-top click hover"
@click=${() => setClipboard(scimUrl, $l("SCIM Tenant Url"))}
>
<div class="half-padded">
<div class="tiny blue highlighted">${$l("Tenant URL")}</div>
<div class="small">
<code>${scimUrl}</code>
</div>
</div>
</div>
<div
class="padded border-top click hover"
@click=${() => setClipboard(scimSecretToken, $l("SCIM Secret Token"))}
>
<div class="half-padded">
<div class="tiny blue highlighted">${$l("Secret Token")}</div>
<div class="small">
<code>${scimSecretToken}</code>
</div>
</div>
</div>
</pl-drawer>
</section>
</div>
`;
}
}

View File

@ -49,11 +49,12 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
private _groups: { name: string; readonly: boolean }[] = [];
@state()
private _members: { id: string; name: string; readonly: boolean }[] = [];
private _members: { email: string; name: string; readonly: boolean }[] = [];
private get _availableMembers() {
return (
(this._org && this._org.members.filter((member) => !this._members.some((m) => m.id === member.id))) || []
(this._org && this._org.members.filter((member) => !this._members.some((m) => m.email === member.email))) ||
[]
);
}
@ -96,12 +97,12 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
return [];
}
const members: { id: string; name: string; readonly: boolean }[] = [];
const members: { email: string; name: string; readonly: boolean }[] = [];
for (const member of this._org.members) {
const vault = member.vaults.find((v) => v.id === this.vaultId);
if (vault) {
members.push({ id: member.id, name: member.name, readonly: vault.readonly });
members.push({ email: member.email, name: member.name, readonly: vault.readonly });
}
}
@ -127,7 +128,7 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
const hasMembersChanged =
this._members.length !== currentMembers.length ||
this._members.some((member) => {
const other = currentMembers.find((m) => m.id === member.id);
const other = currentMembers.find((m) => m.email === member.email);
return !other || other.readonly !== member.readonly;
});
@ -147,8 +148,8 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
}
}
private _addMember({ id, name }: OrgMember) {
this._members.push({ id, name, readonly: false });
private _addMember({ email, name }: OrgMember) {
this._members.push({ email, name, readonly: false });
this.requestUpdate();
}
@ -158,7 +159,7 @@ export class VaultView extends Routing(StateMixin(LitElement)) {
}
private _removeMember(member: OrgMember) {
this._members = this._members.filter((m) => m.id !== member.id);
this._members = this._members.filter((m) => m.email !== member.email);
}
private _removeGroup(group: { name: string }) {

View File

@ -191,18 +191,24 @@ export class Account extends PBES2Container implements Storable {
throw "Account needs to be unlocked first";
}
if (!org.publicKey) {
throw "Org has not been initialized yet!";
}
const member = org.getMember(this);
if (!member) {
throw new Err(ErrorCode.VERIFICATION_ERROR, "Account is not a member.");
}
const verified = await getProvider().verify(
this.signingKey,
member.orgSignature,
concatBytes([stringToBytes(org.id), org.publicKey], 0x00),
new HMACParams()
);
const verified =
member.orgSignature &&
(await getProvider().verify(
this.signingKey,
member.orgSignature,
concatBytes([stringToBytes(org.id), org.publicKey!], 0x00),
new HMACParams()
));
if (!verified) {
throw new Err(ErrorCode.VERIFICATION_ERROR, `Failed to verify public key of ${org.name}!`);

View File

@ -3,7 +3,7 @@ import { Storable } from "./storage";
import { Serializable, Serialize, AsDate, AsSerializable, bytesToBase64, stringToBytes, equalBytes } from "./encoding";
import { Invite, InvitePurpose } from "./invite";
import { Vault, VaultID } from "./vault";
import { Org, OrgID, OrgMember, OrgRole, Group, UnlockedOrg, OrgInfo } from "./org";
import { Org, OrgID, OrgMember, OrgRole, Group, UnlockedOrg, OrgInfo, ActiveOrgMember, OrgMemberStatus } from "./org";
import { VaultItem, VaultItemID, Field, Tag, createVaultItem, AuditResult } from "./item";
import { Account, AccountID, UnlockedAccount } from "./account";
import { Auth } from "./auth";
@ -512,6 +512,7 @@ export class App {
await this.fetchAuthInfo();
await this.fetchAccount();
await this.fetchOrgs();
await this.autoHandleInvites();
await this.syncVaults();
await this.save();
@ -887,7 +888,7 @@ export class App {
// Suspend members and create confirmation invites
for (const member of org.members.filter((m) => m.id !== account.id)) {
member.role = OrgRole.Suspended;
member.status = OrgMemberStatus.Suspended;
const invite = new Invite(member.email, "confirm_membership");
await invite.initialize(org, this.account!);
org.invites.push(invite);
@ -895,11 +896,11 @@ export class App {
// Update own membership
await org.addOrUpdateMember({
id: account.id,
accountId: account.id,
email: account.email,
name: account.name,
publicKey: account.publicKey,
orgSignature: await account.signOrg(org),
orgSignature: await account.signOrg({ id: org.id, publicKey: org.publicKey! }),
role: OrgRole.Owner,
});
});
@ -983,7 +984,7 @@ export class App {
async createVault(
name: string,
org: Org,
members: { id: AccountID; readonly: boolean }[] = [],
members: { email: string; accountId?: AccountID; readonly: boolean }[] = [],
groups: { name: string; readonly: boolean }[] = []
): Promise<Vault> {
if (!members.length && !groups.length) {
@ -1008,11 +1009,11 @@ export class App {
}
group.vaults.push({ id: vault.id, readonly });
});
members.forEach(({ id, readonly }) => {
const member = org.getMember({ id });
members.forEach(({ accountId, email, readonly }) => {
const member = org.getMember({ accountId, email });
if (!member) {
setTimeout(() => {
throw `Member not found: ${id}`;
throw `Member not found: ${email}`;
});
return;
}
@ -1033,7 +1034,7 @@ export class App {
/** The new vault name */
name: string,
/** Organization members that should have access to the vault */
members: { id: AccountID; readonly: boolean }[] = [],
members: { email: string; id?: AccountID; readonly: boolean }[] = [],
/** Groups that should have access to the vault */
groups: { name: string; readonly: boolean }[] = []
) {
@ -1242,7 +1243,7 @@ export class App {
}
const org = vault.org && this.getOrg(vault.org.id);
const accessors = (org ? org.getAccessors(vault) : [account]) as OrgMember[];
const accessors = (org ? org.getAccessors(vault) : [account]) as ActiveOrgMember[];
const accessorsChanged =
vault.accessors.length !== accessors.length ||
@ -1585,8 +1586,6 @@ export class App {
let org = new Org();
org.name = name;
org = await this.api.createOrg(org);
await org.initialize(this.account!);
org = await this.api.updateOrg(org);
await this.fetchAccount();
return this.fetchOrg(org);
}
@ -1609,7 +1608,7 @@ export class App {
async fetchOrg({ id, revision }: { id: OrgID; revision?: string }) {
const existing = this.getOrg(id);
if (existing && existing.revision === revision && existing.members.length) {
if (existing && existing.revision === revision && existing.publicKey) {
return existing;
}
@ -1621,7 +1620,7 @@ export class App {
throw new Err(ErrorCode.VERIFICATION_ERROR, "'minMemberUpdated' property may not decrease!");
}
if (this.account && !this.account.locked && org.owner === this.account.id && !org.members.length) {
if (this.account && !this.account.locked && org.isOwner(this.account) && !org.publicKey) {
await org.initialize(this.account);
org = await this.api.updateOrg(org);
}
@ -1671,7 +1670,7 @@ export class App {
async createGroup(
org: Org,
name: string,
members: { id: AccountID }[],
members: { email: string }[],
vaults: { id: VaultID; readonly: boolean }[]
) {
if (name.toLowerCase() === "new") {
@ -1701,7 +1700,7 @@ export class App {
members,
vaults,
name: newName,
}: { members?: { id: AccountID }[]; vaults?: { id: VaultID; readonly: boolean }[]; name?: string }
}: { members?: { email: string }[]; vaults?: { id: VaultID; readonly: boolean }[]; name?: string }
) {
await this.updateOrg(org.id, async (org) => {
const group = org.getGroup(name);
@ -1731,22 +1730,24 @@ export class App {
*/
async updateMember(
org: Org,
{ id }: OrgMember,
{ email, accountId }: OrgMember,
{
vaults,
groups,
role,
status,
}: {
vaults?: { id: VaultID; readonly: boolean }[];
groups?: string[];
role?: OrgRole;
status?: OrgMemberStatus;
}
): Promise<OrgMember> {
if (!this.account || this.account.locked) {
throw "App needs to be logged in and unlocked to update an organization member!";
}
await this.updateOrg(org.id, async (org) => {
const member = org.getMember({ id })!;
const member = org.getMember({ email, accountId })!;
// Update assigned vaults
if (vaults) {
@ -1757,24 +1758,24 @@ export class App {
if (groups) {
// Remove member from all groups
for (const group of org.groups) {
group.members = group.members.filter((m) => m.id !== id);
group.members = group.members.filter((m) => m.email !== email);
}
// Add them back to the assigned groups
for (const name of groups) {
const group = org.getGroup(name)!;
group.members.push({ id });
group.members.push({ email, accountId });
}
}
// Update member role
if (role && member.role !== role) {
if ((role && member.role !== role) || (status && member.status !== status)) {
await org.unlock(this.account as UnlockedAccount);
await org.addOrUpdateMember({ ...member, role });
await org.addOrUpdateMember({ ...member, role, status });
}
});
return this.getOrg(org.id)!.getMember({ id })!;
return this.getOrg(org.id)!.getMember({ email, accountId })!;
}
/**
@ -1882,7 +1883,7 @@ export class App {
org.removeInvite(invite);
});
return this.getOrg(invite.org!.id)!.getMember({ id: invite.invitee!.id })!;
return this.getOrg(invite.org!.id)!.getMember({ email: invite.invitee!.email })!;
}
/**
@ -1895,6 +1896,29 @@ export class App {
);
}
async autoHandleInvites(): Promise<void> {
if (!this.account || this.account.locked) {
return;
}
for (const org of this.orgs.filter((org) => org.isOwner(this.account!))) {
let newMembers: string[] = [];
for (const member of org.members) {
if (
member.status === OrgMemberStatus.Provisioned &&
!org.invites.some((inv) => inv.email === member.email)
) {
newMembers.push(member.email);
}
}
if (newMembers.length) {
await this.createInvites(org, newMembers);
}
}
}
/**
* =============
* ATTACHMENTS

View File

@ -0,0 +1,206 @@
import { Org, OrgID, OrgMember, OrgMemberStatus, Group } from "./org";
import { Server } from "./server";
import { getIdFromEmail, uuid } from "./util";
import { Auth } from "./auth";
import { Err, ErrorCode } from "./error";
export interface DirectoryUser {
externalId?: string;
email: string;
active: boolean;
name?: string;
}
export interface DirectoryGroup {
externalId?: string;
name: string;
members: DirectoryUser[];
}
export interface DirectorySubscriber {
userCreated(user: DirectoryUser, orgId: OrgID): Promise<void>;
userUpdated(user: DirectoryUser, orgId: OrgID, previousEmail?: string): Promise<void>;
userDeleted(user: DirectoryUser, orgId: OrgID): Promise<void>;
groupCreated(group: DirectoryGroup, orgId: OrgID): Promise<void>;
groupUpdated(group: DirectoryGroup, orgId: OrgID, previousName?: string): Promise<void>;
groupDeleted(group: DirectoryGroup, orgId: OrgID): Promise<void>;
}
export interface DirectoryProvider {
subscribe(sub: DirectorySubscriber): void;
}
export class DirectorySync implements DirectorySubscriber {
constructor(public readonly server: Server, providers: DirectoryProvider[] = []) {
for (const provider of providers) {
provider.subscribe(this);
}
}
async userCreated(user: DirectoryUser, orgId: string) {
const org = (orgId && (await this.server.storage.get(Org, orgId))) || null;
if (org && org.directory.syncMembers) {
const memberExists = org.members.some((member) => member.email === user.email);
if (memberExists) {
return;
}
const newOrgMember = new OrgMember({
id: await uuid(),
name: user.name,
email: user.email,
status: OrgMemberStatus.Provisioned,
updated: new Date(),
});
org.members.push(newOrgMember);
await this.server.updateMetaData(org);
await this.server.storage.save(org);
}
}
async userUpdated(user: DirectoryUser, orgId: string, previousEmail?: string) {
const org = (orgId && (await this.server.storage.get(Org, orgId))) || null;
if (org && org.directory.syncMembers) {
// The existence of the `previousEmail` argument indicates that the member's
// email has changed, so we'll have to look up the member using the previous email
const existingMember = org.getMember({ email: previousEmail || user.email });
if (!existingMember) {
return;
}
// Changing the email address of a user is a little more
// involved than that. Let's disable it for now.
if (previousEmail && previousEmail !== user.email) {
throw new Err(ErrorCode.NOT_SUPPORTED, "Updating user emails is not supported at this time");
}
// if (user.email) {
// existingMember.email = user.email;
// }
if (user.name) {
existingMember.name = user.name;
}
existingMember.updated = new Date();
await this.server.updateMetaData(org);
await this.server.storage.save(org);
}
}
async userDeleted(user: DirectoryUser, orgId: string) {
const org = (orgId && (await this.server.storage.get(Org, orgId))) || null;
if (org && org.directory.syncMembers) {
const existingMember = org.getMember({ email: user.email });
if (!existingMember) {
return;
}
await org.removeMember(existingMember, false);
// Remove any existing invites
const existingInvite = org.invites.find((invite) => invite.email === existingMember!.email);
if (existingInvite) {
org.removeInvite(existingInvite);
try {
const auth = await this._getAuthForEmail(existingMember.email);
auth.invites = auth.invites.filter((invite) => invite.id !== existingInvite.id);
await this.server.storage.save(auth);
} catch (_error) {
// Ignore
}
}
await this.server.updateMetaData(org);
await this.server.storage.save(org);
}
}
async groupCreated(group: DirectoryGroup, orgId: string) {
const org = (orgId && (await this.server.storage.get(Org, orgId))) || null;
if (org && org.directory.syncGroups) {
const groupExists = org.groups.some((orgGroup) => orgGroup.name === group.name);
if (groupExists) {
return;
}
let members = [];
for (const { email } of group.members) {
const member = org.getMember({ email });
if (!member) {
continue;
}
members.push(member);
}
const newGroup = new Group({
name: group.name,
members,
});
org.groups.push(newGroup);
await this.server.updateMetaData(org);
await this.server.storage.save(org);
}
}
async groupUpdated(group: DirectoryGroup, orgId: string, previousName?: string) {
const org = (orgId && (await this.server.storage.get(Org, orgId))) || null;
if (org && org.directory.syncGroups) {
// If the name has changed we have to look for the group using the previous name
const existingGroup = org.groups.find((g) => g.name === (previousName || group.name));
if (!existingGroup) {
return;
}
existingGroup.name = group.name;
let members = [];
for (const { email } of group.members) {
const member = org.getMember({ email });
if (!member) {
continue;
}
members.push({
email,
accountId: member.accountId,
});
}
existingGroup.members = members;
await this.server.updateMetaData(org);
await this.server.storage.save(org);
}
}
async groupDeleted(group: DirectoryGroup, orgId: string) {
const org = (orgId && (await this.server.storage.get(Org, orgId))) || null;
if (org && org.directory.syncGroups) {
const existingGroupIndex = org.groups.findIndex((orgGroup) => orgGroup.name === group.name);
if (existingGroupIndex === -1) {
return;
}
org.groups.splice(existingGroupIndex, 1);
await this.server.updateMetaData(org);
await this.server.storage.save(org);
}
}
private async _getAuthForEmail(email: string) {
const auth = await this.server.storage.get(Auth, await getIdFromEmail(email));
return auth;
}
}

View File

@ -239,7 +239,7 @@ export class Invite extends SimpleContainer {
id: org.id,
name: org.name,
publicKey: org.publicKey,
signature: await this._sign(concatBytes([stringToBytes(org.id), org.publicKey], 0x00)),
signature: await this._sign(concatBytes([stringToBytes(org.id), org.publicKey!], 0x00)),
});
}

View File

@ -36,9 +36,61 @@ export const MIGRATIONS: Migration[] = [
},
},
},
{
from: "3.1.0",
to: "4.0.0",
transforms: {
/**
* - `id` property was renamed to `accountId`
* - Removed `OrgRole.Suspended` in favor of the new `status` property,
* which can be set to `OrgMemberStatus.Suspended`.
*/
orgmember: {
up: ({ id, role, ...rest }) => ({
accountId: id,
role: role === 3 ? 2 : role,
status: role === 3 ? "suspended" : "active",
...rest,
}),
down: ({ accountId, role, status, ...rest }) => ({
id: accountId,
role: status === "suspended" ? 3 : role,
...rest,
}),
},
/**
* Members are now primarily referenced by email since
* `accountId` may not be defined yet for provisioned members.
* This is actually a transform on `OrgGroup` but we need
* to implement it on the `Org` level since we need access
* to the `member` property to look up emails.
*/
org: {
up: ({ members, groups, ...rest }) => ({
members,
groups: groups.map(({ members: groupMembers, ...rest }: any) => ({
members: groupMembers.map(({ id }: any) => ({
accountId: id,
email: members.find((m: any) => m.id === id)?.email,
})),
...rest,
})),
...rest,
}),
down: ({ groups, ...rest }) => ({
groups: groups.map(({ members, ...rest }: any) => ({
members: members.map(({ accountId }: any) => ({ id: accountId })),
...rest,
})),
...rest,
}),
},
},
},
];
export const EARLIEST_VERSION = MIGRATIONS[0].from;
export const VERSIONS = [EARLIEST_VERSION, ...MIGRATIONS.map((m) => m.to)];
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].to;
function norm(version: string = EARLIEST_VERSION): string {
@ -49,6 +101,10 @@ function norm(version: string = EARLIEST_VERSION): string {
}
export function upgrade(kind: string, raw: any, version: string = LATEST_VERSION): any {
if (!raw.version) {
raw.version = EARLIEST_VERSION;
}
if (norm(raw.version) > norm(LATEST_VERSION)) {
throw new Err(
ErrorCode.UNSUPPORTED_VERSION,
@ -57,37 +113,42 @@ export function upgrade(kind: string, raw: any, version: string = LATEST_VERSION
);
}
const migration = MIGRATIONS.find(
(m) => norm(m.from) >= norm(raw.version || EARLIEST_VERSION) && norm(m.to) <= norm(version)
);
// Find nearest revision
const targetVersion = [...VERSIONS].reverse().find((v) => norm(v) <= norm(version)) || EARLIEST_VERSION;
const closestVersion = VERSIONS.find((v) => norm(v) > norm(raw.version)) || LATEST_VERSION;
const migrateToVersion = norm(closestVersion) < norm(targetVersion) ? closestVersion : targetVersion;
const migration = MIGRATIONS.find((m) => m.to === migrateToVersion && m.to !== raw.version);
if (migration) {
let transform = migration.transforms["all"];
raw = transform ? transform.up(raw, kind) : raw;
transform = migration.transforms[kind];
raw = transform ? transform.up(raw, kind) : raw;
raw.version = migration.to;
return upgrade(kind, raw, version);
} else {
raw.version = version;
if (!migration) {
return raw;
}
let transform = migration.transforms["all"];
raw = transform ? transform.up(raw, kind) : raw;
transform = migration.transforms[kind];
raw = transform ? transform.up(raw, kind) : raw;
raw.version = migration.to;
return upgrade(kind, raw, version);
}
export function downgrade(kind: string, raw: any, version: string = LATEST_VERSION): any {
const migration = MIGRATIONS.reverse().find(
(m) => norm(m.to) <= norm(raw.version || LATEST_VERSION) && norm(m.from) >= norm(version)
);
if (!raw.version) {
raw.version = LATEST_VERSION;
}
if (migration) {
let transform = migration.transforms[kind];
raw = transform ? transform.down(raw, kind) : raw;
transform = migration.transforms["all"];
raw = transform ? transform.down(raw, kind) : raw;
raw.version = migration.from;
return downgrade(kind, raw, version);
} else {
raw.version = norm(version) > norm(LATEST_VERSION) ? LATEST_VERSION : version;
const targetVersion = [...VERSIONS].reverse().find((v) => norm(v) <= norm(version)) || EARLIEST_VERSION;
const closestVersion = [...VERSIONS].reverse().find((v) => norm(v) < norm(raw.version)) || EARLIEST_VERSION;
const migrateToVersion = norm(closestVersion) > norm(targetVersion) ? closestVersion : targetVersion;
const migration = MIGRATIONS.find((m) => m.from === migrateToVersion && m.from !== raw.version);
if (!migration) {
return raw;
}
let transform = migration.transforms[kind];
raw = transform ? transform.down(raw, kind) : raw;
transform = migration.transforms["all"];
raw = transform ? transform.down(raw, kind) : raw;
raw.version = migration.from;
return downgrade(kind, raw, version);
}

View File

@ -31,16 +31,27 @@ export enum OrgRole {
* Member information (like public key and email address) of suspended members
* are considered unverified, and need to be updated and verified via a
* membership confirmation [[Invite]].
* @deprecated Use `OrgMemberStatus.Suspended` instead
*/
Suspended,
}
export enum OrgMemberStatus {
Provisioned = "provisioned",
Active = "active",
Suspended = "suspended",
}
/**
* Represents an [[Account]]s membership to an [[Org]]
*/
export class OrgMember extends Serializable {
get id() {
return this.accountId;
}
/** id of the corresponding [[Account]] */
id: AccountID = "";
accountId?: AccountID = undefined;
/** name of the corresponding [[Account]] */
name = "";
@ -50,15 +61,15 @@ export class OrgMember extends Serializable {
/** public key of the corresponding [[Account]] */
@AsBytes()
publicKey!: RSAPublicKey;
publicKey?: RSAPublicKey;
/** signature used by other members to verify [[id]], [[email]] and [[publicKey]] */
@AsBytes()
signature!: Uint8Array;
signature?: Uint8Array;
/** signature used by the member to verify [[Org.id]] and [[Org.publickey]] of the organization */
@AsBytes()
orgSignature!: Uint8Array;
orgSignature?: Uint8Array;
/** vaults assigned to this member */
vaults: {
@ -69,17 +80,47 @@ export class OrgMember extends Serializable {
/** the members organization role */
role: OrgRole = OrgRole.Member;
status: OrgMemberStatus = OrgMemberStatus.Active;
/** time the member was last updated */
@AsDate()
updated = new Date(0);
constructor({ id, name, email, publicKey, signature, orgSignature, role, updated }: Partial<OrgMember> = {}) {
constructor({
accountId,
name,
email,
publicKey,
signature,
orgSignature,
role,
updated,
status,
}: Partial<OrgMember> = {}) {
super();
Object.assign(this, { id, name, email, publicKey, signature, orgSignature, updated });
Object.assign(this, {
accountId,
name,
email,
publicKey,
signature,
orgSignature,
updated,
status,
});
this.role = typeof role !== "undefined" && role in OrgRole ? role : OrgRole.Member;
}
}
export interface ActiveOrgMember extends OrgMember {
id: string;
status: OrgMemberStatus.Active;
accountId: AccountID;
publicKey: Uint8Array;
signature: Uint8Array;
orgSignature: Uint8Array;
}
/**
* A group of members, used to manage [[Vault]] access for multiple members at once.
*/
@ -92,7 +133,7 @@ export class Group extends Serializable {
/** display name */
name = "";
/** members assigned to this group */
members: { id: AccountID }[] = [];
members: { email: string; accountId?: AccountID }[] = [];
/** [[Vault]]s assigned to this group */
vaults: {
id: VaultID;
@ -119,10 +160,30 @@ export class OrgSecrets extends Serializable {
export interface OrgInfo {
id: OrgID;
name: string;
owner: AccountID;
owner?: {
email: string;
accountId?: AccountID;
};
revision: string;
}
export class ScimSettings extends Serializable {
@AsBytes()
secret!: Uint8Array;
secretToken: string = "";
url: string = "";
}
export class OrgDirectorySettings extends Serializable {
syncProvider: "scim" | "none" = "none";
syncGroups: boolean = true;
syncMembers: boolean = true;
@AsSerializable(ScimSettings)
scim?: ScimSettings;
}
/**
* Organizations are the central component of Padlocs secure data sharing architecture.
*
@ -169,9 +230,6 @@ export class Org extends SharedContainer implements Storable {
/** Unique identier */
id: OrgID = "";
/** [[Account]] which created this organization */
owner: AccountID = "";
/** Organization name */
name: string = "";
@ -185,7 +243,7 @@ export class Org extends SharedContainer implements Storable {
/** Public key used for verifying member signatures */
@AsBytes()
publicKey!: RSAPublicKey;
publicKey?: RSAPublicKey;
/**
* Private key used for signing member details
@ -238,45 +296,61 @@ export class Org extends SharedContainer implements Storable {
@AsSerializable(Invite)
invites: Invite[] = [];
@AsSerializable(OrgDirectorySettings)
directory: OrgDirectorySettings = new OrgDirectorySettings();
/**
* Revision id used for ensuring continuity when synchronizing the account
* object between client and server
*/
revision: string = "";
/** [[Account]] which created this organization */
get owner() {
return this.members.find((member) => member.role === OrgRole.Owner);
}
get info(): OrgInfo {
return {
id: this.id,
name: this.name,
owner: this.owner,
owner: this.owner && {
email: this.owner.email,
accountId: this.owner.accountId,
},
revision: this.revision,
};
}
/** Whether the given [[Account]] is an [[OrgRole.Owner]] */
isOwner({ id }: { id: AccountID }) {
return this.owner === id;
isOwner({ email }: { email: string }) {
return this.owner?.email === email;
}
/** Whether the given [[Account]] is an [[OrgRole.Admin]] */
isAdmin(m: { id: AccountID }) {
isAdmin(m: { email: string }) {
const member = this.getMember(m);
return !!member && member.role <= OrgRole.Admin;
}
/** Whether the given [[Account]] is currently suspended */
isSuspended(m: { id: AccountID }) {
isSuspended(m: { email: string }) {
const member = this.getMember(m);
return !!member && member.role === OrgRole.Suspended;
return !!member && member.status === OrgMemberStatus.Suspended;
}
/** Get the [[OrgMember]] object for this [[Account]] */
getMember({ id }: { id: AccountID }) {
return this.members.find((m) => m.id === id);
getMember({
email,
accountId,
}: { email: string; accountId?: AccountID } | { accountId: AccountID; email?: string }) {
return accountId
? this.members.find((m) => m.accountId === accountId)
: this.members.find((m) => m.email === email);
}
/** Whether the given [[Account]] is an organization member */
isMember(acc: { id: AccountID }) {
isMember(acc: { email: string }) {
return !!this.getMember(acc);
}
@ -296,8 +370,8 @@ export class Org extends SharedContainer implements Storable {
}
/** Get all [[Group]]s the given [[Account]] is a member of */
getGroupsForMember({ id }: { id: AccountID }) {
return this.groups.filter((g) => g.members.some((m) => m.id === id));
getGroupsForMember({ email }: { email: string }) {
return this.groups.filter((g) => g.members.some((m) => m.email === email));
}
/** Get all groups assigned to a given [[Vault]] */
@ -306,21 +380,25 @@ export class Org extends SharedContainer implements Storable {
}
/** Get all members directly assigned to a given [[Vault]] */
getMembersForVault({ id }: { id: VaultID }): OrgMember[] {
getMembersForVault({ id }: { id: VaultID }): ActiveOrgMember[] {
return this.members.filter(
(member) => member.role !== OrgRole.Suspended && member.vaults.some((v) => v.id === id)
);
(member) =>
member.status === OrgMemberStatus.Active &&
member.accountId &&
member.publicKey &&
member.vaults.some((v) => v.id === id)
) as ActiveOrgMember[];
}
/** Get all membes that have acess to a given `vault`, either directly or through a [[Group]] */
getAccessors(vault: Vault) {
const results = new Set<OrgMember>(this.getMembersForVault(vault));
getAccessors(vault: Vault): ActiveOrgMember[] {
const results = new Set<ActiveOrgMember>(this.getMembersForVault(vault));
for (const group of this.getGroupsForVault(vault)) {
for (const m of group.members) {
const member = this.getMember(m);
if (member && member.role !== OrgRole.Suspended) {
results.add(member);
if (member && member.status === OrgMemberStatus.Active && member.publicKey && member.accountId) {
results.add(member as ActiveOrgMember);
}
}
}
@ -329,7 +407,7 @@ export class Org extends SharedContainer implements Storable {
}
/** Get all vaults the given member has access to */
getVaultsForMember(acc: OrgMember | Account) {
getVaultsForMember(acc: { email: string }) {
const member = this.getMember(acc);
if (!member) {
@ -348,7 +426,7 @@ export class Org extends SharedContainer implements Storable {
}
/** Check whether the given `account` has read access to a `vault` */
canRead(vault: { id: VaultID }, account: { id: AccountID }) {
canRead(vault: { id: VaultID }, account: { email: string }) {
const member = this.getMember(account);
return (
@ -358,12 +436,12 @@ export class Org extends SharedContainer implements Storable {
}
/** Check whether the given `account` has write access to a `vault` */
canWrite(vault: { id: VaultID }, acc: { id: AccountID }) {
canWrite(vault: { id: VaultID }, acc: { email: string }) {
const member = this.getMember(acc);
return (
member &&
member.role !== OrgRole.Suspended &&
member.status === OrgMemberStatus.Active &&
[member, ...this.getGroupsForMember(member)].some(({ vaults }) =>
vaults.some((v) => v.id === vault.id && !v.readonly)
)
@ -395,10 +473,14 @@ export class Org extends SharedContainer implements Storable {
// Set minimum date for member update times
this.minMemberUpdated = new Date();
const orgSignature = await account.signOrg(this);
const member = await this.sign(
const orgSignature = await account.signOrg({
id: this.id,
publicKey: this.publicKey!,
});
await this.addOrUpdateMember(
new OrgMember({
id: account.id,
accountId: account.id,
name: account.name,
email: account.email,
publicKey: account.publicKey,
@ -407,7 +489,6 @@ export class Org extends SharedContainer implements Storable {
updated: new Date(),
})
);
this.members.push(member);
}
/**
@ -435,11 +516,11 @@ export class Org extends SharedContainer implements Storable {
await this.generateKeys();
// Rotate org encryption key
await this.updateAccessors(this.members.filter((m) => m.role === OrgRole.Owner));
await this.updateAccessors([this.getMember(this.owner!) as ActiveOrgMember]);
// Re-sign all members
await Promise.all(
this.members.filter((m) => m.role !== OrgRole.Suspended).map((m) => this.addOrUpdateMember(m))
this.members.filter((m) => m.status === OrgMemberStatus.Active).map((m) => this.addOrUpdateMember(m))
);
}
@ -470,11 +551,15 @@ export class Org extends SharedContainer implements Storable {
throw "Organisation needs to be unlocked first.";
}
if (!member.publicKey) {
throw "The member needs to be assigned a public key first.";
}
member.signature = await getProvider().sign(
this.privateKey,
concatBytes(
[
stringToBytes(member.id),
stringToBytes(member.accountId || ""),
stringToBytes(member.email),
new Uint8Array([member.role]),
member.publicKey,
@ -492,7 +577,7 @@ export class Org extends SharedContainer implements Storable {
* Throws if verification fails.
*/
async verify(member: OrgMember): Promise<void> {
if (!member.signature) {
if (!member.signature || !member.publicKey || !this.publicKey) {
throw new Err(ErrorCode.VERIFICATION_ERROR, "No signed public key provided!");
}
@ -503,7 +588,7 @@ export class Org extends SharedContainer implements Storable {
member.signature,
concatBytes(
[
stringToBytes(member.id),
stringToBytes(member.accountId || ""),
stringToBytes(member.email),
new Uint8Array([member.role]),
member.publicKey,
@ -522,7 +607,7 @@ export class Org extends SharedContainer implements Storable {
/**
* Verify all provided `members`, throws if verification fails for any of them.
*/
async verifyAll(members: OrgMember[] = this.members.filter((m) => m.role !== OrgRole.Suspended)) {
async verifyAll(members: OrgMember[] = this.members.filter((m) => m.status === OrgMemberStatus.Active)) {
// Verify public keys for members and groups
await Promise.all(members.map(async (obj) => this.verify(obj)));
}
@ -531,19 +616,21 @@ export class Org extends SharedContainer implements Storable {
* Adds a member to the organization, or updates the existing member with the same id.
*/
async addOrUpdateMember({
id,
accountId,
name,
email,
publicKey,
orgSignature,
role,
status,
}: {
id: string;
accountId?: string;
name: string;
email: string;
publicKey: Uint8Array;
orgSignature: Uint8Array;
publicKey?: Uint8Array;
orgSignature?: Uint8Array;
role?: OrgRole;
status?: OrgMemberStatus;
}) {
if (!this.privateKey) {
throw "Organisation needs to be unlocked first.";
@ -551,15 +638,17 @@ export class Org extends SharedContainer implements Storable {
role = typeof role !== "undefined" ? role : OrgRole.Member;
const existing = this.members.find((m) => m.id === id);
const existing = this.getMember({ email, accountId });
const updated = new Date();
if (existing) {
Object.assign(existing, { name, email, publicKey, orgSignature, role, updated });
Object.assign(existing, { name, email, accountId, publicKey, orgSignature, role, status, updated });
await this.sign(existing);
} else {
this.members.push(
await this.sign(new OrgMember({ id, name, email, publicKey, orgSignature, role, updated }))
await this.sign(
new OrgMember({ accountId, name, email, publicKey, orgSignature, role, status, updated })
)
);
}
}
@ -567,18 +656,18 @@ export class Org extends SharedContainer implements Storable {
/**
* Removes a member from the organization
*/
async removeMember(member: { id: AccountID }, reSignMembers = true) {
async removeMember(member: { email: string }, reSignMembers = true) {
if (reSignMembers && !this.privateKey) {
throw "Organisation needs to be unlocked first.";
}
// Remove member from all groups
for (const group of this.getGroupsForMember(member)) {
group.members = group.members.filter((m) => m.id !== member.id);
group.members = group.members.filter((m) => m.email !== member.email);
}
// Remove member
this.members = this.members.filter((m) => m.id !== member.id);
this.members = this.members.filter((m) => m.email !== member.email);
if (reSignMembers) {
// Verify remaining members (since we're going to re-sign them)
@ -589,7 +678,7 @@ export class Org extends SharedContainer implements Storable {
// Re-sign all members
await Promise.all(
this.members.filter((m) => m.role !== OrgRole.Suspended).map((m) => this.addOrUpdateMember(m))
this.members.filter((m) => m.status === OrgMemberStatus.Active).map((m) => this.addOrUpdateMember(m))
);
}
}
@ -597,7 +686,7 @@ export class Org extends SharedContainer implements Storable {
/**
* Transfers organization ownership to a different member
*/
async makeOwner(member: { id: AccountID }) {
async makeOwner(member: { email: string }) {
if (!this.privateKey) {
throw "Organisation needs to be unlocked first.";
}
@ -606,7 +695,7 @@ export class Org extends SharedContainer implements Storable {
await this.verifyAll();
const newOwner = this.getMember(member);
const existingOwner = this.getMember({ id: this.owner })!;
const existingOwner = this.getMember(this.owner!)!;
if (!newOwner || !existingOwner) {
throw "New and/or existing owner not found.";
@ -614,12 +703,11 @@ export class Org extends SharedContainer implements Storable {
newOwner.role = OrgRole.Owner;
existingOwner.role = OrgRole.Admin;
this.owner = newOwner.id;
await this.addOrUpdateMember(newOwner);
await this.addOrUpdateMember(existingOwner);
await this.updateAccessors(this.members.filter((m) => m.role === OrgRole.Owner));
await this.updateAccessors([newOwner as ActiveOrgMember]);
}
toString() {

View File

@ -1,4 +1,5 @@
import { Account, AccountID } from "./account";
import { Config, ConfigParam } from "./config";
import { AsSerializable, Serializable } from "./encoding";
import { Err, ErrorCode } from "./error";
import { Org, OrgID, OrgInfo } from "./org";
@ -159,7 +160,10 @@ export class OrgProvisioning extends Storable {
orgName: string = "";
owner: AccountID = "";
owner: {
email: string;
accountId?: AccountID;
} = { email: "" };
status: ProvisioningStatus = ProvisioningStatus.Active;
@ -201,8 +205,8 @@ export interface Provisioner {
orgDeleted(params: OrgInfo): Promise<void>;
orgOwnerChanged(
org: OrgInfo,
prevOwner: { email: string; id: AccountID },
newOwner: { email: string; id: AccountID }
prevOwner: { email: string; id?: AccountID },
newOwner: { email: string; id?: AccountID }
): Promise<void>;
}
@ -220,8 +224,46 @@ export class StubProvisioner implements Provisioner {
): Promise<void> {}
}
export class DefaultAccountProvisioning
extends Config
implements Pick<AccountProvisioning, "status" | "statusLabel" | "statusMessage" | "actionUrl" | "actionLabel">
{
constructor(vals: Partial<DefaultAccountProvisioning> = {}) {
super();
Object.assign(this, vals);
}
@ConfigParam()
status: ProvisioningStatus = ProvisioningStatus.Active;
@ConfigParam()
statusLabel: string = "";
@ConfigParam()
statusMessage: string = "";
@ConfigParam()
actionUrl?: string;
@ConfigParam()
actionLabel?: string;
}
export class BasicProvisionerConfig extends Config {
constructor(vals: Partial<BasicProvisionerConfig> = {}) {
super();
Object.assign(this, vals);
}
@ConfigParam(DefaultAccountProvisioning)
default: DefaultAccountProvisioning = new DefaultAccountProvisioning();
}
export class BasicProvisioner implements Provisioner {
constructor(public readonly storage: Storage) {}
constructor(
public readonly storage: Storage,
public readonly config: BasicProvisionerConfig = new BasicProvisionerConfig()
) {}
async getProvisioning({
email,
@ -252,7 +294,7 @@ export class BasicProvisioner implements Provisioner {
orgIds.map((orgId) =>
this._getOrCreateOrgProvisioning(orgId).then((prov) => {
// Delete messages meant for owner if this org is not owned by this user
if (prov.owner !== provisioning.account.accountId) {
if (prov.owner.email !== provisioning.account.email) {
for (const feature of Object.values(prov.features)) {
delete feature.messageOwner;
}
@ -277,9 +319,8 @@ export class BasicProvisioner implements Provisioner {
async orgDeleted({ id }: OrgInfo): Promise<void> {
try {
const orgProv = await this.storage.get(OrgProvisioning, id);
const owner = await this.storage.get(Account, orgProv.owner);
await this.storage.delete(new OrgProvisioning({ orgId: id }));
const accountProv = await this.storage.get(AccountProvisioning, await getIdFromEmail(owner.email));
const accountProv = await this._getOrCreateAccountProvisioning(orgProv.owner);
accountProv.orgs = accountProv.orgs.filter((id) => id !== orgProv.id);
await this.storage.save(accountProv);
} catch (e) {
@ -291,8 +332,8 @@ export class BasicProvisioner implements Provisioner {
async orgOwnerChanged(
{ id }: OrgInfo,
prevOwner: { email: string; id: AccountID },
newOwner: { email: string; id: AccountID }
prevOwner: { email: string; id?: AccountID },
newOwner: { email: string; id?: AccountID }
) {
const [orgProv, prevOwnerProv, newOwnerProv] = await Promise.all([
this._getOrCreateOrgProvisioning(id),
@ -307,7 +348,7 @@ export class BasicProvisioner implements Provisioner {
);
}
orgProv.owner = newOwner.id;
orgProv.owner = newOwner;
prevOwnerProv.orgs = prevOwnerProv.orgs.filter((o) => o !== id);
newOwnerProv.orgs.push(id);
@ -318,7 +359,20 @@ export class BasicProvisioner implements Provisioner {
]);
}
private async _getOrCreateAccountProvisioning({ email, accountId }: { email: string; accountId?: AccountID }) {
protected _getDefaultAccountProvisioning() {
return this.storage.get(AccountProvisioning, "[default]").catch(
() =>
new AccountProvisioning({
status: this.config.default.status,
statusLabel: this.config.default.statusLabel,
statusMessage: this.config.default.statusMessage,
actionUrl: this.config.default.actionUrl,
actionLabel: this.config.default.actionLabel,
})
);
}
protected async _getOrCreateAccountProvisioning({ email, accountId }: { email: string; accountId?: AccountID }) {
let prov: AccountProvisioning;
const id = await getIdFromEmail(email);
@ -329,14 +383,17 @@ export class BasicProvisioner implements Provisioner {
throw e;
}
prov = new AccountProvisioning({ id, email, accountId });
prov = await this._getDefaultAccountProvisioning();
prov.id = id;
prov.email = email;
prov.accountId = accountId;
await this.storage.save(prov);
}
return prov;
}
private async _getOrCreateOrgProvisioning(orgId: OrgID) {
protected async _getOrCreateOrgProvisioning(orgId: OrgID) {
let prov: OrgProvisioning;
try {
prov = await this.storage.get(OrgProvisioning, orgId);

View File

@ -1,4 +1,4 @@
import { Serializable, stringToBase64 } from "./encoding";
import { Serializable, stringToBase64, bytesToBase64 } from "./encoding";
import {
API,
StartCreateSessionParams,
@ -40,7 +40,7 @@ import {
import { Request, Response } from "./transport";
import { Err, ErrorCode } from "./error";
import { Vault, VaultID } from "./vault";
import { Org, OrgID, OrgRole } from "./org";
import { Org, OrgID, OrgMember, OrgMemberStatus, OrgRole, ScimSettings } from "./org";
import { Invite } from "./invite";
import {
ConfirmMembershipInviteMessage,
@ -82,6 +82,10 @@ export class ServerConfig extends Config {
@ConfigParam("string[]")
defaultAuthTypes: AuthType[] = [AuthType.Email];
/** URL where the SCIM directory server is hosted, if used. Used for creating URLs for integrations */
@ConfigParam()
scimServerUrl = "http://localhost:5000";
constructor(init: Partial<ServerConfig> = {}) {
super();
Object.assign(this, init);
@ -653,7 +657,7 @@ export class Controller extends API {
const auth = (this.context.auth = await this._getAuth(account.email));
this.context.provisioning = await this.provisioner.getProvisioning(auth);
// Make sure that no account with this email exists and that the email is not blocked from singing up
// Make sure that no account with this email exists and that the email is not blocked from signing up
if (auth.account) {
throw new Err(ErrorCode.ACCOUNT_EXISTS, "This account already exists!");
}
@ -708,7 +712,13 @@ export class Controller extends API {
org.name = orgName;
org.id = orgId;
org.revision = await uuid();
org.owner = account.id;
org.members = [
new OrgMember({
accountId: account.id,
email: account.email,
status: OrgMemberStatus.Provisioned,
}),
];
org.created = new Date();
org.updated = new Date();
await this.storage.save(org);
@ -818,7 +828,7 @@ export class Controller extends API {
const org = await this.storage.get(Org, id);
if (!org.isOwner(account)) {
const member = org.getMember(account)!;
member.role = OrgRole.Suspended;
member.status = OrgMemberStatus.Suspended;
await this.storage.save(org);
}
}
@ -882,9 +892,16 @@ export class Controller extends API {
org.id = await uuid();
org.revision = await uuid();
org.owner = account.id;
org.created = new Date();
org.updated = new Date();
org.members = [
new OrgMember({
accountId: account.id,
email: account.email,
role: OrgRole.Owner,
status: OrgMemberStatus.Provisioned,
}),
];
await this.storage.save(org);
@ -903,7 +920,7 @@ export class Controller extends API {
// Only members can read organization data. For non-members,
// we pretend the organization doesn't exist.
if (org.owner !== account.id && !org.isMember(account)) {
if (!org.isMember(account)) {
throw new Err(ErrorCode.NOT_FOUND);
}
@ -928,6 +945,7 @@ export class Controller extends API {
revision,
minMemberUpdated,
owner,
directory,
}: Org) {
const { account, provisioning } = this._requireAuth();
@ -949,7 +967,7 @@ export class Controller extends API {
throw new Err(ErrorCode.OUTDATED_REVISION);
}
const isOwner = org.owner === account.id || org.isOwner(account);
const isOwner = org.owner?.id === account.id || org.isOwner(account);
const isAdmin = isOwner || org.isAdmin(account);
// Only admins can make any changes to organizations at all.
@ -966,7 +984,7 @@ export class Controller extends API {
}
const addedMembers = members.filter((m) => !org.isMember(m));
const removedMembers = org.members.filter(({ id }) => !members.some((m) => id === m.id));
const removedMembers = org.members.filter(({ email }) => !members.some((m) => email === m.email));
const addedInvites = invites.filter(({ id }) => !org.getInvite(id));
const removedInvites = org.invites.filter(({ id }) => !invites.some((inv) => id === inv.id));
const addedGroups = groups.filter((group) => !org.getGroup(group.name));
@ -979,8 +997,8 @@ export class Controller extends API {
removedMembers.length ||
addedInvites.length ||
removedInvites.length ||
members.some(({ id, role }) => {
const member = org.getMember({ id });
members.some(({ email, role }) => {
const member = org.getMember({ email });
return !member || member.role !== role;
}))
) {
@ -1014,14 +1032,25 @@ export class Controller extends API {
members,
groups,
vaults,
directory,
});
if (org.owner !== owner) {
await this.provisioner.orgOwnerChanged(
org,
org.getMember({ id: org.owner })!,
org.getMember({ id: owner })!
);
if (org.directory.syncProvider === "scim") {
if (!org.directory.scim) {
org.directory.scim = new ScimSettings();
org.directory.scim.secret = await getCryptoProvider().randomBytes(16);
const scimSecret = bytesToBase64(org.directory.scim.secret, true);
org.directory.scim.secretToken = scimSecret;
org.directory.scim.url = `${this.config.scimServerUrl}/${org.id}`;
}
} else if (org.directory.syncProvider === "none") {
org.directory.scim = undefined;
org.directory.syncGroups = false;
org.directory.syncMembers = false;
}
if (org.owner && owner && org.owner.email !== owner.email) {
await this.provisioner.orgOwnerChanged(org, org.getMember(org.owner)!, org.getMember(owner)!);
}
// certain properties may only be updated by organization owners
@ -1036,7 +1065,6 @@ export class Controller extends API {
accessors,
invites,
minMemberUpdated,
owner,
});
}
@ -1135,11 +1163,14 @@ export class Controller extends API {
}
// Removed members
for (const { id, name, email } of removedMembers) {
for (const { accountId, name, email } of removedMembers) {
if (!accountId) {
continue;
}
promises.push(
(async () => {
try {
const acc = await this.storage.get(Account, id);
const acc = await this.storage.get(Account, accountId);
acc.orgs = acc.orgs.filter((o) => o.id !== org.id);
await this.storage.save(acc);
} catch (e) {
@ -1199,11 +1230,13 @@ export class Controller extends API {
// Remove org from all member accounts
await Promise.all(
org.members.map(async (member) => {
const acc = await this.storage.get(Account, member.id);
acc.orgs = acc.orgs.filter(({ id }) => id !== org.id);
await this.storage.save(acc);
})
org.members
.filter((m) => !!m.accountId)
.map(async (member) => {
const acc = await this.storage.get(Account, member.accountId!);
acc.orgs = acc.orgs.filter(({ id }) => id !== org.id);
await this.storage.save(acc);
})
);
await this.storage.delete(org);
@ -1982,10 +2015,13 @@ export class Server {
// Update org info on members
for (const member of org.members) {
if (!member.accountId) {
continue;
}
promises.push(
(async () => {
try {
const acc = await this.storage.get(Account, member.id);
const acc = await this.storage.get(Account, member.accountId!);
acc.orgs = [...acc.orgs.filter((o) => o.id !== org.id), org.info];
@ -1997,7 +2033,7 @@ export class Server {
throw e;
}
deletedMembers.add(member.id);
deletedMembers.add(member.accountId!);
}
})()
);
@ -2006,7 +2042,7 @@ export class Server {
await Promise.all(promises);
org.vaults = org.vaults.filter((v) => !deletedVaults.has(v.id));
org.members = org.members.filter((m) => !deletedMembers.has(m.id));
org.members = org.members.filter((m) => !m.accountId || !deletedMembers.has(m.accountId));
}
private async _addToQueue(context: Context) {

View File

@ -217,3 +217,17 @@ export function stripPropertiesRecursive(obj: object, properties: string[]) {
export function removeTrailingSlash(url: string) {
return url.replace(/(\/*)$/, "");
}
export function setPath(obj: any, path: string, value: any) {
const [firstProperty, ...otherProperties] = path.split(".");
let subObject = obj[firstProperty];
if (otherProperties.length) {
if (!subObject) {
subObject = obj[firstProperty] = {};
}
setPath(subObject, otherProperties.join("."), value);
} else {
obj[path] = value;
}
}

View File

@ -10,11 +10,14 @@ import { AuthType } from "@padloc/core/src/auth";
import { OpenIdConfig } from "./auth/openid";
import { TotpAuthConfig } from "@padloc/core/src/auth/totp";
import { StripeProvisionerConfig } from "./provisioning/stripe";
import { DirectoryProvisionerConfig } from "./provisioning/directory";
import { MixpanelConfig } from "./logging/mixpanel";
import { HTTPReceiverConfig } from "./transport/http";
import { PostgresConfig } from "./storage/postgres";
import dotenv from "dotenv";
import { resolve } from "path";
import { ScimServerConfig } from "./scim";
import { BasicProvisionerConfig } from "@padloc/core/src/provisioning";
export class TransportConfig extends Config {
@ConfigParam()
@ -110,10 +113,24 @@ export class AuthConfig extends Config {
export class ProvisioningConfig extends Config {
@ConfigParam()
backend: "basic" | "stripe" = "basic";
backend: "basic" | "directory" | "stripe" = "basic";
@ConfigParam(BasicProvisionerConfig)
basic?: BasicProvisionerConfig;
@ConfigParam(StripeProvisionerConfig)
stripe?: StripeProvisionerConfig;
@ConfigParam(DirectoryProvisionerConfig)
directory?: DirectoryProvisionerConfig;
}
export class DirectoryConfig extends Config {
@ConfigParam("string[]")
providers: "scim"[] = ["scim"];
@ConfigParam(ScimServerConfig)
scim?: ScimServerConfig;
}
export class PadlocConfig extends Config {
@ -145,6 +162,9 @@ export class PadlocConfig extends Config {
@ConfigParam(ProvisioningConfig)
provisioning = new ProvisioningConfig();
@ConfigParam(DirectoryConfig)
directory = new DirectoryConfig();
}
export function getConfig() {

View File

@ -23,7 +23,7 @@ import {
} from "./config";
import { MemoryStorage, VoidStorage } from "@padloc/core/src/storage";
import { MemoryAttachmentStorage } from "@padloc/core/src/attachment";
import { BasicProvisioner } from "@padloc/core/src/provisioning";
import { BasicProvisioner, BasicProvisionerConfig } from "@padloc/core/src/provisioning";
import { OpenIDServer } from "./auth/openid";
import { TotpAuthConfig, TotpAuthServer } from "@padloc/core/src/auth/totp";
import { EmailAuthServer } from "@padloc/core/src/auth/email";
@ -35,6 +35,9 @@ import { MixpanelLogger } from "./logging/mixpanel";
import { PostgresStorage } from "./storage/postgres";
import { ErrorCode } from "@padloc/core/src/error";
import { stripPropertiesRecursive } from "@padloc/core/src/util";
import { DirectoryProvisioner } from "./provisioning/directory";
import { ScimServer, ScimServerConfig } from "./scim";
import { DirectoryProvider, DirectorySync } from "@padloc/core/src/directory";
const rootDir = resolve(__dirname, "../../..");
const assetsDir = resolve(rootDir, process.env.PL_ASSETS_DIR || "assets");
@ -199,10 +202,20 @@ async function initAuthServers(config: PadlocConfig) {
return servers;
}
async function initProvisioner(config: PadlocConfig, storage: Storage) {
async function initProvisioner(config: PadlocConfig, storage: Storage, directoryProviders?: DirectoryProvider[]) {
switch (config.provisioning.backend) {
case "basic":
return new BasicProvisioner(storage);
if (!config.provisioning.basic) {
config.provisioning.basic = new BasicProvisionerConfig();
}
return new BasicProvisioner(storage, config.provisioning.basic);
case "directory":
const directoryProvisioner = new DirectoryProvisioner(
storage,
directoryProviders,
config.provisioning.directory
);
return directoryProvisioner;
case "stripe":
if (!config.provisioning.stripe) {
throw "PL_PROVISIONING_BACKEND was set to 'stripe', but no related configuration was found!";
@ -211,10 +224,32 @@ async function initProvisioner(config: PadlocConfig, storage: Storage) {
await stripeProvisioner.init();
return stripeProvisioner;
default:
throw `Invalid value for PL_PROVISIONING_BACKEND: ${config.provisioning.backend}! Supported values: "simple", "stripe"`;
throw `Invalid value for PL_PROVISIONING_BACKEND: ${config.provisioning.backend}! Supported values: "basic", "directory", "stripe"`;
}
}
async function initDirectoryProviders(config: PadlocConfig, storage: Storage) {
if (!config.directory) {
return [];
}
let providers: DirectoryProvider[] = [];
for (const provider of config.directory.providers) {
switch (provider) {
case "scim":
if (!config.directory.scim) {
config.directory.scim = new ScimServerConfig();
}
const scimServer = new ScimServer(config.directory.scim, storage);
await scimServer.init();
providers.push(scimServer);
break;
default:
throw `Invalid value for PL_DIRECTORY_PROVIDERS: ${provider}! Supported values: "scim"`;
}
}
return providers;
}
async function init(config: PadlocConfig) {
setPlatform(new NodePlatform());
@ -223,7 +258,8 @@ async function init(config: PadlocConfig) {
const logger = await initLogger(config.logging);
const attachmentStorage = await initAttachmentStorage(config.attachments);
const authServers = await initAuthServers(config);
const provisioner = await initProvisioner(config, storage);
const directoryProviders = await initDirectoryProviders(config, storage);
const provisioner = await initProvisioner(config, storage, directoryProviders);
let legacyServer: NodeLegacyServer | undefined = undefined;
@ -234,6 +270,10 @@ async function init(config: PadlocConfig) {
});
}
if (config.directory.scim && !config.server.scimServerUrl) {
config.server.scimServerUrl = config.directory.scim.url;
}
const server = new Server(
config.server,
storage,
@ -245,6 +285,8 @@ async function init(config: PadlocConfig) {
legacyServer
);
new DirectorySync(server, directoryProviders);
// Skip starting listener if --dryrun flag is present
if (process.argv.includes("--dryrun")) {
process.exit(0);
@ -292,3 +334,6 @@ async function start() {
}
start();
function DirectoryProvider() {
throw new Error("Function not implemented.");
}

View File

@ -1,7 +1,7 @@
import {
AccountProvisioning,
AccountQuota,
BasicProvisioner,
BasicProvisionerConfig,
Provisioning,
ProvisioningStatus,
} from "@padloc/core/src/provisioning";
@ -21,39 +21,12 @@ export class DefaultAccountQuota extends Config implements AccountQuota {
storage = 1000;
}
export class DefaultAccountProvisioning
extends Config
implements
Pick<AccountProvisioning, "status" | "statusLabel" | "statusMessage" | "actionUrl" | "actionLabel" | "quota">
{
@ConfigParam()
status: ProvisioningStatus = ProvisioningStatus.Active;
@ConfigParam()
statusLabel: string = "";
@ConfigParam()
statusMessage: string = "";
@ConfigParam()
actionUrl?: string;
@ConfigParam()
actionLabel?: string;
@ConfigParam(DefaultAccountQuota)
quota: DefaultAccountQuota = new DefaultAccountQuota();
}
export class ApiProvisionerConfig extends Config {
export class ApiProvisionerConfig extends BasicProvisionerConfig {
@ConfigParam("number")
port: number = 4000;
@ConfigParam("string", true)
apiKey?: string;
@ConfigParam(DefaultAccountProvisioning)
default: DefaultAccountProvisioning = new DefaultAccountProvisioning();
}
interface ProvisioningUpdate {
@ -98,7 +71,7 @@ export class ProvisioningEntry extends Provisioning {
export class ApiProvisioner extends BasicProvisioner {
constructor(public readonly config: ApiProvisionerConfig, public readonly storage: Storage) {
super(storage);
super(storage, config);
}
protected async _getProvisioningEntry({ email, accountId }: { email: string; accountId?: string | undefined }) {
@ -120,32 +93,15 @@ export class ApiProvisioner extends BasicProvisioner {
}
}
const account = await this._getDefaultAccountProvisioning();
account.id = id;
(account.email = email), (account.accountId = accountId);
const provisioning = new ProvisioningEntry({
id,
account: new AccountProvisioning({
email,
accountId,
status: this.config.default.status,
statusLabel: this.config.default.statusLabel,
statusMessage: this.config.default.statusMessage,
actionUrl: this.config.default.actionUrl,
actionLabel: this.config.default.actionLabel,
quota: this.config.default.quota,
}),
account,
});
try {
const {
account: { status, statusLabel, statusMessage, actionUrl, actionLabel },
} = await this.storage.get(ProvisioningEntry, "[default]");
provisioning.account.status = status;
provisioning.account.statusLabel = statusLabel;
provisioning.account.statusMessage = statusMessage;
provisioning.account.actionUrl = actionUrl;
provisioning.account.actionLabel = actionLabel;
} catch (e) {}
return provisioning;
}

View File

@ -0,0 +1,61 @@
import {
BasicProvisioner,
BasicProvisionerConfig,
DefaultAccountProvisioning,
ProvisioningStatus,
} from "@padloc/core/src/provisioning";
import { Storage } from "@padloc/core/src/storage";
import { DirectoryGroup, DirectoryProvider, DirectorySubscriber, DirectoryUser } from "@padloc/core/src/directory";
export class DirectoryProvisionerConfig extends BasicProvisionerConfig {}
export class DirectoryProvisioner extends BasicProvisioner implements DirectorySubscriber {
constructor(
public readonly storage: Storage,
public readonly providers: DirectoryProvider[] = [],
public readonly config: DirectoryProvisionerConfig = new DirectoryProvisionerConfig()
) {
super(storage, config);
this.config.default = new DefaultAccountProvisioning({
status: ProvisioningStatus.Unprovisioned,
statusLabel: "Access Denied",
statusMessage: "You currently don't have access to this service. Please contact the service administrator.",
});
for (const provider of providers) {
provider.subscribe(this);
}
}
async userCreated(user: DirectoryUser) {
if (user.email) {
const accountProv = await this._getOrCreateAccountProvisioning({ email: user.email });
accountProv.status = user.active ? ProvisioningStatus.Active : ProvisioningStatus.Suspended;
await this.storage.save(accountProv);
}
}
async userUpdated(user: DirectoryUser) {
if (user.email) {
const accountProv = await this._getOrCreateAccountProvisioning({ email: user.email });
accountProv.status = user.active ? ProvisioningStatus.Active : ProvisioningStatus.Suspended;
await this.storage.save(accountProv);
}
}
async userDeleted(user: DirectoryUser) {
if (user.email) {
const accountProv = await this._getOrCreateAccountProvisioning({ email: user.email });
return super.accountDeleted(accountProv);
}
}
groupCreated(_group: DirectoryGroup, _orgId: string) {
return Promise.resolve();
}
groupUpdated(_group: DirectoryGroup, _orgId: string, _previousName?: string) {
return Promise.resolve();
}
groupDeleted(_group: DirectoryGroup, _orgId: string) {
return Promise.resolve();
}
}

View File

@ -1,7 +1,7 @@
import Stripe from "stripe";
import { Storage } from "@padloc/core/src/storage";
import { readBody } from "../transport/http";
import { Config, ConfigParam } from "@padloc/core/src/config";
import { ConfigParam } from "@padloc/core/src/config";
import {
AccountFeatures,
AccountQuota,
@ -13,6 +13,7 @@ import {
BasicProvisioner,
AccountProvisioning,
Provisioning,
BasicProvisionerConfig,
} from "@padloc/core/src/provisioning";
import { uuid } from "@padloc/core/src/util";
import { Org, OrgInfo } from "@padloc/core/src/org";
@ -23,7 +24,7 @@ import { HMACKeyParams, HMACParams } from "@padloc/core/src/crypto";
import { URLSearchParams } from "url";
import { Account } from "@padloc/core/src/account";
export class StripeProvisionerConfig extends Config {
export class StripeProvisionerConfig extends BasicProvisionerConfig {
@ConfigParam("string", true)
secretKey!: string;
@ -193,8 +194,10 @@ export class StripeProvisioner extends BasicProvisioner {
async orgDeleted(org: OrgInfo): Promise<void> {
await super.orgDeleted(org);
const account = await this.storage.get(Account, org.owner);
const provisioning = await this.getProvisioning(account);
const provisioning = org.owner && (await this.getProvisioning(org.owner));
if (!provisioning) {
return;
}
const { tier } = this._getSubscriptionInfo(provisioning.account.metaData.customer);
if ([Tier.Business, Tier.Team, Tier.Family].includes(tier)) {
@ -584,7 +587,10 @@ export class StripeProvisioner extends BasicProvisioner {
: tier === Tier.Business
? "My Business"
: "My Org"),
owner: account.accountId,
owner: {
email: account.email,
accountId: account.accountId,
},
autoCreate: !org,
quota,
features: await this._getOrgFeatures(customer, tier, quota, org),
@ -666,7 +672,7 @@ export class StripeProvisioner extends BasicProvisioner {
account.billingPage = await this._renderBillingPage(customer, paymentMethods, latestInvoice);
const existingOrg = orgs.find((o) => o.owner === account.accountId);
const existingOrg = orgs.find((o) => o.owner.email === account.email);
if (existingOrg || [Tier.Family, Tier.Team, Tier.Business].includes(tier)) {
const org = await this._getOrgProvisioning(account, customer, existingOrg);

870
packages/server/src/scim.ts Normal file
View File

@ -0,0 +1,870 @@
import { Storage } from "@padloc/core/src/storage";
import { Config, ConfigParam } from "@padloc/core/src/config";
import { Org, OrgID } from "@padloc/core/src/org";
import { DirectoryProvider, DirectorySubscriber, DirectoryUser, DirectoryGroup } from "@padloc/core/src/directory";
import { createServer, IncomingMessage, ServerResponse } from "http";
import { getCryptoProvider } from "@padloc/core/src/platform";
import { base64ToBytes } from "@padloc/core/src/encoding";
import { setPath, uuid } from "@padloc/core/src/util";
import { readBody } from "./transport/http";
import { OrgProvisioning } from "@padloc/core/src/provisioning";
export class ScimServerConfig extends Config {
@ConfigParam()
url = "http://localhost:5000";
@ConfigParam("number")
port: number = 5000;
}
interface ScimUserEmail {
value: string;
type: "work" | "home" | "other";
primary?: boolean;
}
interface ScimUserName {
formatted: string;
givenName?: string;
familyName?: string;
}
interface ScimUser {
id?: string;
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"];
externalId?: string;
userName?: string;
active: boolean;
meta: {
resourceType: "User";
created: string;
lastModified: string;
location: string;
version?: string;
};
name: ScimUserName;
displayName?: string;
emails?: ScimUserEmail[];
}
interface ScimUserPatchOperation {
op: "replace" | "Replace";
path?: "userName" | "name.formatted" | "active" | "name" | "emails";
value: any;
}
interface ScimUserPatch {
Operations: ScimUserPatchOperation[];
}
interface ScimGroupMember {
$ref: string | null;
value: string;
display?: string;
}
interface ScimGroup {
id?: string;
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"];
externalId?: string;
displayName: string;
meta: {
resourceType: "Group";
created: string;
lastModified: string;
location: string;
version?: string;
};
members: ScimGroupMember[];
}
interface ScimGroupPatchOperation {
op: "replace" | "Replace" | "add" | "Add" | "remove" | "Remove";
path?: "displayName" | "members";
value: any;
}
interface ScimGroupPatch {
schemas: string[];
Operations: ScimGroupPatchOperation[];
}
interface ScimOrg {
users: ScimUser[];
groups: ScimGroup[];
}
interface ScimError {
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"];
status: number;
scimType?: string;
detail: string;
}
interface ScimListResponse {
// NOTE: This isn't part of the RFC spec, but Azure fails without it
id: string;
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
totalResults: number;
Resources: (ScimGroup | ScimUser)[];
startIndex: number;
itemsPerPage: number;
}
export class ScimServer implements DirectoryProvider {
private _subscribers: DirectorySubscriber[] = [];
constructor(public readonly config: ScimServerConfig, public readonly storage: Storage) {}
subscribe(sub: DirectorySubscriber) {
this._subscribers.push(sub);
}
async init() {
await this._startScimServer();
}
private async _getScimOrg(orgId: OrgID) {
try {
const prov = await this.storage.get(OrgProvisioning, orgId);
return (prov.metaData?.scim || { users: [], groups: [] }) as ScimOrg;
} catch (e) {
return null;
}
}
private async _saveScimOrg(orgId: OrgID, scimOrg: ScimOrg) {
const prov = await this.storage.get(OrgProvisioning, orgId);
prov.metaData = prov.metaData || {};
prov.metaData.scim = scimOrg;
await this.storage.save(prov);
}
private _toDirectoryUser(user: ScimUser): DirectoryUser {
return {
email: this._getScimUserEmail(user),
name: user.name?.formatted,
active: user.active,
externalId: user.externalId,
};
}
private _toDirectoryGroup(org: ScimOrg, group: ScimGroup): DirectoryGroup {
const members = [];
for (const { value } of group.members || []) {
const user = org.users.find((user) => user.id === value);
if (user) {
members.push(this._toDirectoryUser(user));
}
}
return {
name: group.displayName,
members,
};
}
private _getScimUserEmail(user: ScimUser) {
if (!Array.isArray(user.emails) || user.emails?.length === 0) {
// Azure AD tends to use userName as email
if (user.userName?.includes("@")) {
return user.userName;
}
return "";
}
const primaryEmail = user.emails.find((email) => email.primary)?.value;
const workEmail = user.emails.find((email) => email.type === "work")?.value;
const firstEmail = user.emails[0].value;
return primaryEmail || workEmail || firstEmail;
}
private _validateScimUser(user: ScimUser) {
// TODO: Remove this
console.log(JSON.stringify({ user }, null, 2));
if (!this._getScimUserEmail(user)) {
return "User must contain email";
}
if (!user.name?.formatted) {
return "User must contain name.formatted";
}
return null;
}
private _validateScimUserPatchData(patchData: ScimUserPatch) {
if (!Array.isArray(patchData.Operations) || patchData.Operations.length === 0) {
return "No operations detected";
}
if (patchData.Operations.some((operation) => operation.op.toLowerCase() !== "replace")) {
return "Only replace operations are supported";
}
return null;
}
private _validateScimGroup(group: ScimGroup) {
// TODO: Remove this
console.log(JSON.stringify({ group }, null, 2));
if (!group.displayName) {
return "Group must contain displayName";
}
return null;
}
private _validateScimGroupPatchData(patchData: ScimGroupPatch) {
// TODO: Remove this
console.log(JSON.stringify({ patchData }, null, 2));
if (!Array.isArray(patchData.Operations) || patchData.Operations.length === 0) {
return "No operations detected";
}
for (const operation of patchData.Operations) {
if (
operation.op.toLowerCase() === "replace" &&
((operation.path && operation.path !== "displayName") ||
(!operation.path && !operation.value.displayName))
) {
return "Replace operations are only supported for displayName";
}
if (
(operation.op.toLowerCase() === "add" || operation.op.toLowerCase() === "remove") &&
((operation.path && operation.path !== "members") || (!operation.path && !operation.value.members))
) {
return "Add and Remove operations are only supported for members";
}
}
return null;
}
private _sendResponse(httpRes: ServerResponse, status: number, data: Object) {
httpRes.statusCode = status;
httpRes.setHeader("Content-Type", "application/json; charset=utf-8");
httpRes.end(JSON.stringify(data, null, 2));
return;
}
private _sendErrorResponse(httpRes: ServerResponse, status: number, detail: string) {
const scimError: ScimError = {
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
status,
detail,
};
httpRes.statusCode = status;
httpRes.setHeader("Content-Type", "application/json; charset=utf-8");
httpRes.end(JSON.stringify(scimError, null, 2));
}
private _getDataFromScimRequest(httpReq: IncomingMessage) {
const url = new URL(`http://localhost${httpReq.url || ""}`);
const basePath = new URL(this.config.url).pathname.replace(/\/$/, "");
const secretToken =
url.searchParams.get("token") || httpReq.headers.authorization?.replace("Bearer ", "") || "";
const filter = decodeURIComponent(url.searchParams.get("filter") || "").replace(/\+/g, " ");
const matchUrl = url.pathname.match(
new RegExp(`${basePath}/(?<orgId>[^/]+)(?:/(?<resourceType>Users|Groups)(?:/(?<objectId>[^/?#]+))?)?`, "i")
);
const type = matchUrl?.groups?.resourceType?.toLowerCase();
console.log("type", type);
const resourceType = (type === "users" ? "User" : type === "groups" ? "Group" : undefined) as
| "Group"
| "User"
| undefined;
const orgId = matchUrl?.groups?.orgId;
const objectId = matchUrl?.groups?.objectId;
return {
resourceType,
secretToken,
orgId,
objectId,
filter,
};
}
private async _handleScimUsersPost(httpReq: IncomingMessage, httpRes: ServerResponse) {
let newUser: ScimUser;
const { secretToken, orgId } = this._getDataFromScimRequest(httpReq);
if (!secretToken || !orgId) {
return this._sendErrorResponse(httpRes, 400, "Empty SCIM Secret Token / Org Id");
}
try {
const body = await readBody(httpReq);
newUser = JSON.parse(body);
} catch (e) {
return this._sendErrorResponse(httpRes, 400, "Failed to read request body.");
}
const validationError = this._validateScimUser(newUser);
if (validationError) {
return this._sendErrorResponse(httpRes, 400, validationError);
}
try {
const org = await this.storage.get(Org, orgId);
if (!org.directory.scim) {
return this._sendErrorResponse(httpRes, 400, "SCIM has not been configured for this org.");
}
const secretTokenMatches = await getCryptoProvider().timingSafeEqual(
org.directory.scim.secret,
base64ToBytes(secretToken)
);
if (!secretTokenMatches) {
return this._sendErrorResponse(httpRes, 401, "Invalid SCIM Secret Token");
}
const scimOrg = await this._getScimOrg(org.id);
if (!scimOrg) {
return this._sendErrorResponse(httpRes, 404, "An organization with this id does not exist.");
}
const email = this._getScimUserEmail(newUser);
if (scimOrg.users.some((user) => this._getScimUserEmail(user) === email)) {
return this._sendErrorResponse(httpRes, 409, "A user with this email already exists.");
}
// Force just the standard core schema
newUser.schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"];
newUser.id = await uuid();
newUser.meta = {
resourceType: "User",
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
location: this._getUserRef(org, newUser),
};
scimOrg.users.push(newUser);
for (const handler of this._subscribers) {
await handler.userCreated(
{
email,
name: newUser.name.formatted,
active: newUser.active,
},
org.id
);
}
await this._saveScimOrg(orgId, scimOrg);
return this._sendResponse(httpRes, 201, newUser);
} catch (error) {
return this._sendErrorResponse(httpRes, 500, "Unexpected Error");
}
}
private async _handleScimUsersPatch(httpReq: IncomingMessage, httpRes: ServerResponse) {
let patchData: ScimUserPatch;
const { secretToken, orgId, objectId } = this._getDataFromScimRequest(httpReq);
if (!secretToken || !orgId || !objectId) {
return this._sendErrorResponse(httpRes, 400, "Empty SCIM Secret Token / Org Id / User Id");
}
try {
const body = await readBody(httpReq);
patchData = JSON.parse(body);
} catch (e) {
return this._sendErrorResponse(httpRes, 400, "Failed to read request body.");
}
const validationError = this._validateScimUserPatchData(patchData);
if (validationError) {
return this._sendErrorResponse(httpRes, 400, validationError);
}
try {
const org = await this.storage.get(Org, orgId);
if (!org.directory.scim) {
return this._sendErrorResponse(httpRes, 400, "SCIM has not been configured for this org.");
}
const secretTokenMatches = await getCryptoProvider().timingSafeEqual(
org.directory.scim.secret,
base64ToBytes(secretToken)
);
if (!secretTokenMatches) {
return this._sendErrorResponse(httpRes, 401, "Invalid SCIM Secret Token");
}
const scimOrg = await this._getScimOrg(orgId);
if (!scimOrg) {
return this._sendErrorResponse(httpRes, 404, "An organization with this id does not exist.");
}
const userToUpdate = scimOrg.users.find((user) => user.id === objectId);
if (!userToUpdate) {
return this._sendErrorResponse(httpRes, 404, "A user with this id does not exist.");
}
for (const operation of patchData.Operations) {
if (operation.path) {
setPath(userToUpdate, operation.path, operation.value);
} else {
for (const path of Object.keys(operation.value)) {
setPath(userToUpdate, (path as ScimUserPatchOperation["path"])!, operation.value[path]);
}
}
}
for (const handler of this._subscribers) {
await handler.userUpdated(this._toDirectoryUser(userToUpdate), org.id);
}
userToUpdate.meta.lastModified = new Date().toISOString();
await this._saveScimOrg(orgId, scimOrg);
return this._sendResponse(httpRes, 200, userToUpdate);
} catch (error) {
console.error(error);
return this._sendErrorResponse(httpRes, 500, "Unexpected Error");
}
}
private async _handleScimUsersDelete(httpReq: IncomingMessage, httpRes: ServerResponse) {
const { secretToken, orgId, objectId } = this._getDataFromScimRequest(httpReq);
if (!secretToken || !orgId || !objectId) {
return this._sendErrorResponse(httpRes, 400, "Empty SCIM Secret Token / Org Id / User Id");
}
try {
const org = await this.storage.get(Org, orgId);
if (!org.directory.scim) {
return this._sendErrorResponse(httpRes, 400, "SCIM has not been configured for this org.");
}
const secretTokenMatches = await getCryptoProvider().timingSafeEqual(
org.directory.scim.secret,
base64ToBytes(secretToken)
);
if (!secretTokenMatches) {
return this._sendErrorResponse(httpRes, 401, "Invalid SCIM Secret Token");
}
const scimOrg = await this._getScimOrg(orgId);
if (!scimOrg) {
return this._sendErrorResponse(httpRes, 404, "An organization with this id does not exist.");
}
const existingUser = scimOrg.users.find((user) => user.id === objectId);
if (!existingUser) {
return this._sendErrorResponse(httpRes, 404, "A user with this id does not exist!");
}
for (const handler of this._subscribers) {
await handler.userDeleted(this._toDirectoryUser(existingUser), orgId);
}
const existingUserIndex = scimOrg.users.findIndex((user) => user.id === objectId);
scimOrg.users.splice(existingUserIndex, 1);
await this._saveScimOrg(orgId, scimOrg);
} catch (error) {
return this._sendErrorResponse(httpRes, 500, "Unexpected Error");
}
httpRes.statusCode = 204;
httpRes.end();
}
private async _handleScimGroupsPost(httpReq: IncomingMessage, httpRes: ServerResponse) {
let newGroup: ScimGroup;
const { secretToken, orgId } = this._getDataFromScimRequest(httpReq);
if (!secretToken || !orgId) {
return this._sendErrorResponse(httpRes, 400, "Empty SCIM Secret Token / Org Id");
}
try {
const body = await readBody(httpReq);
newGroup = JSON.parse(body);
} catch (e) {
return this._sendErrorResponse(httpRes, 400, "Failed to read request body.");
}
const validationError = this._validateScimGroup(newGroup);
if (validationError) {
return this._sendErrorResponse(httpRes, 400, validationError);
}
try {
const org = await this.storage.get(Org, orgId);
if (!org.directory.scim) {
return this._sendErrorResponse(httpRes, 400, "SCIM has not been configured for this org.");
}
const secretTokenMatches = await getCryptoProvider().timingSafeEqual(
org.directory.scim.secret,
base64ToBytes(secretToken)
);
if (!secretTokenMatches) {
return this._sendErrorResponse(httpRes, 401, "Invalid SCIM Secret Token");
}
const scimOrg = await this._getScimOrg(orgId);
if (!scimOrg) {
return this._sendErrorResponse(httpRes, 404, "An organization with this id does not exist.");
}
if (scimOrg.groups.some((group) => group.displayName === newGroup.displayName)) {
return this._sendErrorResponse(httpRes, 409, "Groups must have unique display names.");
}
// Force just the standard core schema
newGroup.schemas = ["urn:ietf:params:scim:schemas:core:2.0:Group"];
newGroup.id = await uuid();
newGroup.meta = {
resourceType: "Group",
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
location: this._getGroupRef(org, newGroup),
};
newGroup.members = [];
scimOrg.groups.push(newGroup);
for (const handler of this._subscribers) {
await handler.groupCreated(this._toDirectoryGroup(scimOrg, newGroup), org.id);
}
await this._saveScimOrg(orgId, scimOrg);
return this._sendResponse(httpRes, 201, newGroup);
} catch (error) {
console.error(error);
return this._sendErrorResponse(httpRes, 500, "Unexpected Error");
}
}
private async _handleScimGroupsPatch(httpReq: IncomingMessage, httpRes: ServerResponse) {
let patchData: ScimGroupPatch;
const { secretToken, orgId, objectId } = this._getDataFromScimRequest(httpReq);
if (!secretToken || !orgId || !objectId) {
return this._sendErrorResponse(httpRes, 400, "Empty SCIM Secret Token / Org Id / Group Id");
}
try {
const body = await readBody(httpReq);
patchData = JSON.parse(body);
} catch (e) {
return this._sendErrorResponse(httpRes, 400, "Failed to read request body.");
}
const validationError = this._validateScimGroupPatchData(patchData);
if (validationError) {
return this._sendErrorResponse(httpRes, 400, validationError);
}
try {
const org = await this.storage.get(Org, orgId);
if (!org.directory.scim) {
return this._sendErrorResponse(httpRes, 400, "SCIM has not been configured for this org.");
}
const secretTokenMatches = await getCryptoProvider().timingSafeEqual(
org.directory.scim.secret,
base64ToBytes(secretToken)
);
if (!secretTokenMatches) {
return this._sendErrorResponse(httpRes, 401, "Invalid SCIM Secret Token");
}
const scimOrg = await this._getScimOrg(orgId);
if (!scimOrg) {
return this._sendErrorResponse(httpRes, 404, "An organization with this id does not exist.");
}
const existingGroup = scimOrg.groups.find((group) => group.id === objectId);
if (!existingGroup) {
return this._sendErrorResponse(httpRes, 404, "A group with this id does not exist!");
}
const previousName = existingGroup.displayName;
for (const operation of patchData.Operations) {
if (operation.path) {
this._updateGroupAtPath(org, scimOrg, existingGroup, operation.op, operation.path, operation.value);
} else {
for (const path of Object.keys(operation.value)) {
this._updateGroupAtPath(
org,
scimOrg,
existingGroup,
operation.op,
path as ScimGroupPatchOperation["path"],
operation.value[path]
);
}
}
}
existingGroup.meta.lastModified = new Date().toISOString();
for (const handler of this._subscribers) {
await handler.groupUpdated(
this._toDirectoryGroup(scimOrg, existingGroup),
org.id,
previousName !== existingGroup.displayName ? previousName : undefined
);
}
await this._saveScimOrg(orgId, scimOrg);
return this._sendResponse(httpRes, 200, existingGroup);
} catch (error) {
console.error(error);
return this._sendErrorResponse(httpRes, 500, "Unexpected Error");
}
}
private async _handleScimGroupsDelete(httpReq: IncomingMessage, httpRes: ServerResponse) {
const { secretToken, orgId, objectId } = this._getDataFromScimRequest(httpReq);
if (!secretToken || !orgId || !objectId) {
return this._sendErrorResponse(httpRes, 400, "Empty SCIM Secret Token / Org Id / Group Id");
}
try {
const org = await this.storage.get(Org, orgId);
if (!org.directory.scim) {
return this._sendErrorResponse(httpRes, 400, "SCIM has not been configured for this org.");
}
const secretTokenMatches = await getCryptoProvider().timingSafeEqual(
org.directory.scim.secret,
base64ToBytes(secretToken)
);
if (!secretTokenMatches) {
return this._sendErrorResponse(httpRes, 401, "Invalid SCIM Secret Token");
}
const scimOrg = await this._getScimOrg(orgId);
if (!scimOrg) {
return this._sendErrorResponse(httpRes, 404, "An organization with this id does not exist.");
}
const existingGroup = scimOrg.groups.find((group) => group.id === objectId);
if (!existingGroup) {
return this._sendErrorResponse(httpRes, 404, "A group with this id does not exist.");
}
for (const handler of this._subscribers) {
await handler.groupDeleted(this._toDirectoryGroup(scimOrg, existingGroup), orgId);
}
const existingGroupIndex = scimOrg.groups.findIndex((group) => group.id === objectId);
scimOrg.groups.splice(existingGroupIndex, 1);
await this._saveScimOrg(orgId, scimOrg);
} catch (error) {
console.error(error);
return this._sendErrorResponse(httpRes, 500, "Unexpected Error");
}
httpRes.statusCode = 204;
httpRes.end();
}
private async _handleScimGet(httpReq: IncomingMessage, httpRes: ServerResponse) {
const { resourceType, secretToken, orgId, objectId, filter } = this._getDataFromScimRequest(httpReq);
const [queryField, queryOperator, ...queryParts] = filter?.split(" ") || [];
const queryValue = queryParts.join(" ");
if (!secretToken || !orgId) {
return this._sendErrorResponse(httpRes, 400, "Empty SCIM Secret Token / Org Id");
}
const scimOrg = await this._getScimOrg(orgId);
if (!scimOrg) {
return this._sendErrorResponse(httpRes, 404, "An organization with this id does not exist.");
}
const listResponse: ScimListResponse = {
id: await uuid(),
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: 0,
Resources: [],
startIndex: 1,
itemsPerPage: 20,
};
if (!resourceType || resourceType === "Group") {
const groups = scimOrg.groups.filter((group) => {
if (objectId) {
return group.id === objectId;
}
if (queryField === "displayName" && queryOperator === "eq") {
return group.displayName === queryValue.replace(/"/g, "");
}
return true;
});
listResponse.Resources.push(...groups);
}
if (!resourceType || resourceType === "User") {
const users = scimOrg.users.filter((user) => {
if (objectId) {
return user.id === objectId;
}
if (queryField === "userName" && queryOperator === "eq") {
return user.userName === queryValue.replace(/"/g, "");
}
return true;
});
listResponse.Resources.push(...users);
}
// TODO: Add proper pagination
listResponse.totalResults = listResponse.itemsPerPage = listResponse.Resources.length;
// TODO: Remove this
console.log(
JSON.stringify({ listResponse, objectId, orgId, filter, queryField, queryOperator, queryValue }, null, 2)
);
return this._sendResponse(httpRes, 200, listResponse);
}
private _handleScimPost(httpReq: IncomingMessage, httpRes: ServerResponse) {
const url = new URL(`http://localhost${httpReq.url || ""}`);
if (url.pathname.includes("/Groups")) {
return this._handleScimGroupsPost(httpReq, httpRes);
} else if (url.pathname.includes("/Users")) {
return this._handleScimUsersPost(httpReq, httpRes);
}
httpRes.statusCode = 404;
httpRes.end();
}
private _handleScimPatch(httpReq: IncomingMessage, httpRes: ServerResponse) {
const url = new URL(`http://localhost${httpReq.url || ""}`);
if (url.pathname.includes("/Groups")) {
return this._handleScimGroupsPatch(httpReq, httpRes);
} else if (url.pathname.includes("/Users")) {
return this._handleScimUsersPatch(httpReq, httpRes);
}
httpRes.statusCode = 404;
httpRes.end();
}
private _handleScimDelete(httpReq: IncomingMessage, httpRes: ServerResponse) {
const url = new URL(`http://localhost${httpReq.url || ""}`);
if (url.pathname.includes("/Groups")) {
return this._handleScimGroupsDelete(httpReq, httpRes);
} else if (url.pathname.includes("/Users")) {
return this._handleScimUsersDelete(httpReq, httpRes);
}
httpRes.statusCode = 404;
httpRes.end();
}
private async _handleScimRequest(httpReq: IncomingMessage, httpRes: ServerResponse) {
// TODO: Remove this
console.log(JSON.stringify({ method: httpReq.method, url: httpReq.url, headers: httpReq.headers }, null, 2));
switch (httpReq.method) {
case "GET":
return this._handleScimGet(httpReq, httpRes);
case "POST":
return this._handleScimPost(httpReq, httpRes);
case "PATCH":
return this._handleScimPatch(httpReq, httpRes);
case "DELETE":
return this._handleScimDelete(httpReq, httpRes);
default:
httpRes.statusCode = 405;
httpRes.end();
}
}
private async _startScimServer() {
console.log(`Starting SCIM server on port ${this.config.port}`);
const server = createServer((req, res) => this._handleScimRequest(req, res));
server.listen(this.config.port);
}
private _getUserRef(org: Org, user: ScimUser) {
return `${org.directory.scim!.url}/Users/${user.id}`;
}
private _getGroupRef(org: Org, group: ScimGroup) {
return `${org.directory.scim!.url}/Groups/${group.id}`;
}
private _updateGroupAtPath(
org: Org,
scimOrg: ScimOrg,
group: ScimGroup,
operation: ScimGroupPatchOperation["op"],
scimPath: ScimGroupPatchOperation["path"],
value: any
) {
switch (scimPath) {
case "displayName":
group.displayName = value;
break;
case "members":
for (const { value: memberId } of value) {
if (!group.members) {
group.members = [];
}
if (operation.toLowerCase() === "add") {
const user = scimOrg.users.find((user) => user.id === memberId);
if (user && !group.members.some((member) => member.value === memberId)) {
group.members.push({
value: memberId,
display: user.displayName,
$ref: this._getUserRef(org, user),
});
}
} else if (operation.toLowerCase() === "remove") {
group.members = group.members.filter((member) => member.value !== memberId);
}
}
break;
default:
// Ignore all other paths, we don't care about them
}
}
}