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:
commit
62e58f3040
|
@ -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",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
@ -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.
|
|
@ -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);
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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> `
|
||||
: ""}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }) {
|
||||
|
|
|
@ -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}!`);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue