mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 13:23:34 +00:00
[PM-16791] introduce generator profile provider (#13588)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
import { OrganizationId, UserId } from "../types/guid";
|
||||||
|
|
||||||
/** error emitted when the `SingleUserDependency` changes Ids */
|
/** error emitted when the `SingleUserDependency` changes Ids */
|
||||||
export type UserChangedError = {
|
export type UserChangedError = {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { DefaultSemanticLogger } from "./default-semantic-logger";
|
|||||||
import { DisabledSemanticLogger } from "./disabled-semantic-logger";
|
import { DisabledSemanticLogger } from "./disabled-semantic-logger";
|
||||||
import { SemanticLogger } from "./semantic-logger.abstraction";
|
import { SemanticLogger } from "./semantic-logger.abstraction";
|
||||||
|
|
||||||
|
/** A type for injection of a log provider */
|
||||||
|
export type LogProvider = <Context>(context: Jsonify<Context>) => SemanticLogger;
|
||||||
|
|
||||||
/** Instantiates a semantic logger that emits nothing when a message
|
/** Instantiates a semantic logger that emits nothing when a message
|
||||||
* is logged.
|
* is logged.
|
||||||
* @param _context a static payload that is cloned when the logger
|
* @param _context a static payload that is cloned when the logger
|
||||||
@@ -25,8 +28,11 @@ export function disabledSemanticLoggerProvider<Context extends object>(
|
|||||||
* @param settings specializes how the semantic logger functions.
|
* @param settings specializes how the semantic logger functions.
|
||||||
* If this is omitted, the logger suppresses debug messages.
|
* If this is omitted, the logger suppresses debug messages.
|
||||||
*/
|
*/
|
||||||
export function consoleSemanticLoggerProvider(logger: LogService): SemanticLogger {
|
export function consoleSemanticLoggerProvider<Context extends object>(
|
||||||
return new DefaultSemanticLogger(logger, {});
|
logger: LogService,
|
||||||
|
context: Jsonify<Context>,
|
||||||
|
): SemanticLogger {
|
||||||
|
return new DefaultSemanticLogger(logger, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Instantiates a semantic logger that emits logs to the console.
|
/** Instantiates a semantic logger that emits logs to the console.
|
||||||
@@ -42,7 +48,7 @@ export function ifEnabledSemanticLoggerProvider<Context extends object>(
|
|||||||
context: Jsonify<Context>,
|
context: Jsonify<Context>,
|
||||||
) {
|
) {
|
||||||
if (enable) {
|
if (enable) {
|
||||||
return new DefaultSemanticLogger(logger, context);
|
return consoleSemanticLoggerProvider(logger, context);
|
||||||
} else {
|
} else {
|
||||||
return disabledSemanticLoggerProvider(context);
|
return disabledSemanticLoggerProvider(context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export abstract class UserStateSubjectDependencyProvider {
|
|||||||
/** Provides local object persistence */
|
/** Provides local object persistence */
|
||||||
abstract state: StateProvider;
|
abstract state: StateProvider;
|
||||||
|
|
||||||
|
// FIXME: remove `log` and inject the system provider into the USS instead
|
||||||
/** Provides semantic logging */
|
/** Provides semantic logging */
|
||||||
abstract log: <Context extends object>(_context: Jsonify<Context>) => SemanticLogger;
|
abstract log: <Context extends object>(_context: Jsonify<Context>) => SemanticLogger;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const DEFAULT_FRAME_SIZE = 32;
|
|||||||
export class UserStateSubject<
|
export class UserStateSubject<
|
||||||
State extends object,
|
State extends object,
|
||||||
Secret = State,
|
Secret = State,
|
||||||
Disclosed = never,
|
Disclosed = Record<string, never>,
|
||||||
Dependencies = null,
|
Dependencies = null,
|
||||||
>
|
>
|
||||||
extends Observable<State>
|
extends Observable<State>
|
||||||
@@ -243,7 +243,7 @@ export class UserStateSubject<
|
|||||||
// `init$` becomes the accumulator for `scan`
|
// `init$` becomes the accumulator for `scan`
|
||||||
init$.pipe(
|
init$.pipe(
|
||||||
first(),
|
first(),
|
||||||
map((init) => [init, null] as const),
|
map((init) => [init, null] as [State, Dependencies]),
|
||||||
),
|
),
|
||||||
input$.pipe(
|
input$.pipe(
|
||||||
map((constrained) => constrained.state),
|
map((constrained) => constrained.state),
|
||||||
@@ -256,7 +256,7 @@ export class UserStateSubject<
|
|||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
// actual update
|
// actual update
|
||||||
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
|
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
|
||||||
return [next, dependencies];
|
return [next, dependencies] as const;
|
||||||
} else {
|
} else {
|
||||||
// false update
|
// false update
|
||||||
this.log.debug("shouldUpdate prevented write");
|
this.log.debug("shouldUpdate prevented write");
|
||||||
|
|||||||
10
libs/tools/generator/core/src/metadata/index.ts
Normal file
10
libs/tools/generator/core/src/metadata/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { AlgorithmsByType as ABT } from "./data";
|
||||||
|
import { CredentialType, CredentialAlgorithm } from "./type";
|
||||||
|
|
||||||
|
export const AlgorithmsByType: Record<CredentialType, ReadonlyArray<CredentialAlgorithm>> = ABT;
|
||||||
|
|
||||||
|
export { Profile, Type } from "./data";
|
||||||
|
export { GeneratorMetadata } from "./generator-metadata";
|
||||||
|
export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata";
|
||||||
|
export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type";
|
||||||
|
export { isForwarderProfile, isForwarderExtensionId } from "./util";
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { mock } from "jest-mock-extended";
|
||||||
|
import { BehaviorSubject, ReplaySubject, firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||||
|
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider";
|
||||||
|
import { UserEncryptor } from "@bitwarden/common/tools/cryptography/user-encryptor.abstraction";
|
||||||
|
import { disabledSemanticLoggerProvider } from "@bitwarden/common/tools/log";
|
||||||
|
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||||
|
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
|
||||||
|
import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
|
||||||
|
import { StateConstraints } from "@bitwarden/common/tools/types";
|
||||||
|
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
|
import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec";
|
||||||
|
import { CoreProfileMetadata, ProfileContext } from "../metadata/profile-metadata";
|
||||||
|
import { GeneratorConstraints } from "../types";
|
||||||
|
|
||||||
|
import { GeneratorProfileProvider } from "./generator-profile-provider";
|
||||||
|
|
||||||
|
// arbitrary settings types
|
||||||
|
type SomeSettings = { foo: string };
|
||||||
|
|
||||||
|
// fake user information
|
||||||
|
const SomeUser = "SomeUser" as UserId;
|
||||||
|
const AnotherUser = "SomeOtherUser" as UserId;
|
||||||
|
const UnverifiedEmailUser = "UnverifiedEmailUser" as UserId;
|
||||||
|
const accounts: Record<UserId, Account> = {
|
||||||
|
[SomeUser]: {
|
||||||
|
id: SomeUser,
|
||||||
|
name: "some user",
|
||||||
|
email: "some.user@example.com",
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
[AnotherUser]: {
|
||||||
|
id: AnotherUser,
|
||||||
|
name: "some other user",
|
||||||
|
email: "some.other.user@example.com",
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
[UnverifiedEmailUser]: {
|
||||||
|
id: UnverifiedEmailUser,
|
||||||
|
name: "a user with an unverfied email",
|
||||||
|
email: "unverified@example.com",
|
||||||
|
emailVerified: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const accountService = new FakeAccountService(accounts);
|
||||||
|
|
||||||
|
const policyService = mock<PolicyService>();
|
||||||
|
const somePolicy = new Policy({
|
||||||
|
data: { fooPolicy: true },
|
||||||
|
type: PolicyType.PasswordGenerator,
|
||||||
|
id: "" as PolicyId,
|
||||||
|
organizationId: "" as OrganizationId,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateProvider = new FakeStateProvider(accountService);
|
||||||
|
const encryptor = mock<UserEncryptor>();
|
||||||
|
const encryptorProvider = mock<LegacyEncryptorProvider>();
|
||||||
|
|
||||||
|
const dependencyProvider: UserStateSubjectDependencyProvider = {
|
||||||
|
encryptor: encryptorProvider,
|
||||||
|
state: stateProvider,
|
||||||
|
log: disabledSemanticLoggerProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
// settings storage location
|
||||||
|
const SettingsKey = new UserKeyDefinition<SomeSettings>(GENERATOR_DISK, "SomeSettings", {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// fake the configuration
|
||||||
|
const SomeProfile: CoreProfileMetadata<SomeSettings> = {
|
||||||
|
type: "core",
|
||||||
|
storage: {
|
||||||
|
target: "object",
|
||||||
|
key: "SomeSettings",
|
||||||
|
state: GENERATOR_DISK,
|
||||||
|
classifier: new PrivateClassifier(),
|
||||||
|
format: "plain",
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: [],
|
||||||
|
},
|
||||||
|
initial: { foo: "initial" },
|
||||||
|
},
|
||||||
|
constraints: {
|
||||||
|
type: PolicyType.PasswordGenerator,
|
||||||
|
default: { foo: {} },
|
||||||
|
create: jest.fn((policies, context) => {
|
||||||
|
const combined = policies.reduce(
|
||||||
|
(acc, policy) => ({ fooPolicy: acc.fooPolicy || policy.data.fooPolicy }),
|
||||||
|
{ fooPolicy: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (combined.fooPolicy) {
|
||||||
|
return {
|
||||||
|
constraints: {
|
||||||
|
policyInEffect: true,
|
||||||
|
},
|
||||||
|
calibrate(state: SomeSettings) {
|
||||||
|
return {
|
||||||
|
constraints: {},
|
||||||
|
adjust(state: SomeSettings) {
|
||||||
|
return { foo: `adjusted(${state.foo})` };
|
||||||
|
},
|
||||||
|
fix(state: SomeSettings) {
|
||||||
|
return { foo: `fixed(${state.foo})` };
|
||||||
|
},
|
||||||
|
} satisfies StateConstraints<SomeSettings>;
|
||||||
|
},
|
||||||
|
} satisfies GeneratorConstraints<SomeSettings>;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
constraints: {
|
||||||
|
policyInEffect: false,
|
||||||
|
},
|
||||||
|
adjust(state: SomeSettings) {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
fix(state: SomeSettings) {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
} satisfies GeneratorConstraints<SomeSettings>;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const NoPolicyProfile: CoreProfileMetadata<SomeSettings> = {
|
||||||
|
type: "core",
|
||||||
|
storage: {
|
||||||
|
target: "object",
|
||||||
|
key: "SomeSettings",
|
||||||
|
state: GENERATOR_DISK,
|
||||||
|
classifier: new PrivateClassifier(),
|
||||||
|
format: "classified",
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: [],
|
||||||
|
},
|
||||||
|
initial: { foo: "initial" },
|
||||||
|
},
|
||||||
|
constraints: {
|
||||||
|
default: { foo: {} },
|
||||||
|
create: jest.fn((policies, context) => new IdentityConstraint()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("GeneratorProfileProvider", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable());
|
||||||
|
const encryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor });
|
||||||
|
encryptorProvider.userEncryptor$.mockReturnValue(encryptor$);
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
it("writes to the user's state", async () => {
|
||||||
|
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const settings = profileProvider.settings(SomeProfile, { account$ });
|
||||||
|
|
||||||
|
settings.next({ foo: "next value" });
|
||||||
|
await awaitAsync();
|
||||||
|
const result = await firstValueFrom(stateProvider.getUserState$(SettingsKey, SomeUser));
|
||||||
|
|
||||||
|
expect(result).toEqual({ foo: "next value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the user to become available", async () => {
|
||||||
|
await stateProvider.setUserState(SettingsKey, { foo: "initial value" }, SomeUser);
|
||||||
|
const account = new ReplaySubject<Account>(1);
|
||||||
|
const account$ = account.asObservable();
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
|
||||||
|
let result: SomeSettings | undefined = undefined;
|
||||||
|
profileProvider.settings(SomeProfile, { account$ }).subscribe({
|
||||||
|
next(settings) {
|
||||||
|
result = settings;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await awaitAsync();
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
account.next(accounts[SomeUser]);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
// need to use `!` because TypeScript isn't aware that the subscription
|
||||||
|
// sets `result`, and thus computes the type of `result?.userId` as `never`
|
||||||
|
expect(result).toEqual({ foo: "initial value" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("constraints$", () => {
|
||||||
|
it("creates constraints without policy in effect when there is no policy", async () => {
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||||
|
|
||||||
|
const result = await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ }));
|
||||||
|
|
||||||
|
expect(result.constraints.policyInEffect).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates constraints with policy in effect when there is a policy", async () => {
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||||
|
const policy$ = new BehaviorSubject([somePolicy]);
|
||||||
|
policyService.getAll$.mockReturnValue(policy$);
|
||||||
|
|
||||||
|
const result = await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ }));
|
||||||
|
|
||||||
|
expect(result.constraints.policyInEffect).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the policy list to profile.constraint.create(...) when a type is specified", async () => {
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||||
|
const expectedPolicy = [somePolicy];
|
||||||
|
const policy$ = new BehaviorSubject(expectedPolicy);
|
||||||
|
policyService.getAll$.mockReturnValue(policy$);
|
||||||
|
|
||||||
|
await firstValueFrom(profileProvider.constraints$(SomeProfile, { account$ }));
|
||||||
|
|
||||||
|
expect(SomeProfile.constraints.create).toHaveBeenCalledWith(
|
||||||
|
expectedPolicy,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends an empty policy list to profile.constraint.create(...) when a type is omitted", async () => {
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||||
|
|
||||||
|
await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { account$ }));
|
||||||
|
|
||||||
|
expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith([], expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the context to profile.constraint.create(...)", async () => {
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const account$ = new BehaviorSubject(accounts[SomeUser]).asObservable();
|
||||||
|
const expectedContext: ProfileContext<SomeSettings> = {
|
||||||
|
defaultConstraints: NoPolicyProfile.constraints.default,
|
||||||
|
email: accounts[SomeUser].email,
|
||||||
|
};
|
||||||
|
|
||||||
|
await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { account$ }));
|
||||||
|
|
||||||
|
expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith(
|
||||||
|
expect.any(Array),
|
||||||
|
expectedContext,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits nonverified emails from the context sent to profile.constraint.create(...)", async () => {
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const account$ = new BehaviorSubject(accounts[UnverifiedEmailUser]).asObservable();
|
||||||
|
const expectedContext: ProfileContext<SomeSettings> = {
|
||||||
|
defaultConstraints: NoPolicyProfile.constraints.default,
|
||||||
|
};
|
||||||
|
|
||||||
|
await firstValueFrom(profileProvider.constraints$(NoPolicyProfile, { account$ }));
|
||||||
|
|
||||||
|
expect(NoPolicyProfile.constraints.create).toHaveBeenCalledWith(
|
||||||
|
expect.any(Array),
|
||||||
|
expectedContext,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// FIXME: implement this test case once the fake account service mock supports email verification
|
||||||
|
it.todo("invokes profile.constraint.create(...) when the user's email address is verified");
|
||||||
|
|
||||||
|
// FIXME: implement this test case once the fake account service mock supports email updates
|
||||||
|
it.todo("invokes profile.constraint.create(...) when the user's email address changes");
|
||||||
|
|
||||||
|
it("follows policy emissions", async () => {
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const account = new BehaviorSubject(accounts[SomeUser]);
|
||||||
|
const account$ = account.asObservable();
|
||||||
|
const somePolicySubject = new BehaviorSubject([somePolicy]);
|
||||||
|
policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable());
|
||||||
|
const emissions: GeneratorConstraints<SomeSettings>[] = [];
|
||||||
|
const sub = profileProvider
|
||||||
|
.constraints$(SomeProfile, { account$ })
|
||||||
|
.subscribe((policy) => emissions.push(policy));
|
||||||
|
|
||||||
|
// swap the active policy for an inactive policy
|
||||||
|
somePolicySubject.next([]);
|
||||||
|
await awaitAsync();
|
||||||
|
sub.unsubscribe();
|
||||||
|
const [someResult, anotherResult] = emissions;
|
||||||
|
|
||||||
|
expect(someResult.constraints.policyInEffect).toBeTruthy();
|
||||||
|
expect(anotherResult.constraints.policyInEffect).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when the user errors", async () => {
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const account = new BehaviorSubject(accounts[SomeUser]);
|
||||||
|
const account$ = account.asObservable();
|
||||||
|
const expectedError = { some: "error" };
|
||||||
|
|
||||||
|
let actualError: any = null;
|
||||||
|
profileProvider.constraints$(SomeProfile, { account$ }).subscribe({
|
||||||
|
error: (e: unknown) => {
|
||||||
|
actualError = e;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
account.error(expectedError);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(actualError).toEqual(expectedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes when the user completes", async () => {
|
||||||
|
const profileProvider = new GeneratorProfileProvider(dependencyProvider, policyService);
|
||||||
|
const account = new BehaviorSubject(accounts[SomeUser]);
|
||||||
|
const account$ = account.asObservable();
|
||||||
|
|
||||||
|
let completed = false;
|
||||||
|
profileProvider.constraints$(SomeProfile, { account$ }).subscribe({
|
||||||
|
complete: () => {
|
||||||
|
completed = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
account.complete();
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(completed).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
switchMap,
|
||||||
|
takeUntil,
|
||||||
|
shareReplay,
|
||||||
|
tap,
|
||||||
|
of,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
|
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { BoundDependency } from "@bitwarden/common/tools/dependencies";
|
||||||
|
import { SemanticLogger } from "@bitwarden/common/tools/log";
|
||||||
|
import { anyComplete } from "@bitwarden/common/tools/rx";
|
||||||
|
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
|
||||||
|
import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider";
|
||||||
|
|
||||||
|
import { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "../metadata";
|
||||||
|
import { GeneratorConstraints } from "../types/generator-constraints";
|
||||||
|
|
||||||
|
/** Surfaces contextual information to credential generators */
|
||||||
|
export class GeneratorProfileProvider {
|
||||||
|
/** Instantiates the context provider
|
||||||
|
* @param providers dependency injectors for user state subjects
|
||||||
|
* @param policyService settings constraint lookups
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly providers: UserStateSubjectDependencyProvider,
|
||||||
|
private readonly policyService: PolicyService,
|
||||||
|
) {
|
||||||
|
this.log = providers.log({ type: "GeneratorProfileProvider" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly log: SemanticLogger;
|
||||||
|
|
||||||
|
/** Get a subject bound to a specific user's settings for the provided profile.
|
||||||
|
* @param profile determines which profile's settings are loaded
|
||||||
|
* @param dependencies.singleUserId$ identifies the user to which the settings are bound
|
||||||
|
* @returns an observable that emits the subject once `dependencies.singleUserId$` becomes
|
||||||
|
* available and then completes.
|
||||||
|
* @remarks the subject tracks and enforces policy on the settings it contains.
|
||||||
|
* It completes when `dependencies.singleUserId$` competes or the user's encryption key
|
||||||
|
* becomes unavailable.
|
||||||
|
*/
|
||||||
|
settings<Settings extends object>(
|
||||||
|
profile: Readonly<CoreProfileMetadata<Settings>>,
|
||||||
|
dependencies: BoundDependency<"account", Account>,
|
||||||
|
): UserStateSubject<Settings> {
|
||||||
|
const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||||
|
const constraints$ = this.constraints$(profile, { account$ });
|
||||||
|
const subject = new UserStateSubject(profile.storage, this.providers, {
|
||||||
|
constraints$,
|
||||||
|
account$,
|
||||||
|
});
|
||||||
|
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the policy constraints for the provided profile
|
||||||
|
* @param dependencies.account$ constraints are loaded from this account.
|
||||||
|
* If the account's email is verified, it is passed to the constraints
|
||||||
|
* @returns an observable that emits the policy once `dependencies.userId$`
|
||||||
|
* and the policy become available.
|
||||||
|
*/
|
||||||
|
constraints$<Settings>(
|
||||||
|
profile: Readonly<ProfileMetadata<Settings>>,
|
||||||
|
dependencies: BoundDependency<"account", Account>,
|
||||||
|
): Observable<GeneratorConstraints<Settings>> {
|
||||||
|
const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
|
||||||
|
|
||||||
|
const constraints$ = account$.pipe(
|
||||||
|
distinctUntilChanged((prev, next) => {
|
||||||
|
return prev.email === next.email && prev.emailVerified === next.emailVerified;
|
||||||
|
}),
|
||||||
|
switchMap((account) => {
|
||||||
|
this.log.debug(
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
profileType: profile.type,
|
||||||
|
policyType: profile.constraints.type ?? "N/A",
|
||||||
|
defaultConstraints: profile.constraints.default as object,
|
||||||
|
},
|
||||||
|
"initializing constraints$",
|
||||||
|
);
|
||||||
|
|
||||||
|
const policies$ = profile.constraints.type
|
||||||
|
? this.policyService.getAll$(profile.constraints.type, account.id)
|
||||||
|
: of([]);
|
||||||
|
|
||||||
|
const context: ProfileContext<Settings> = {
|
||||||
|
defaultConstraints: profile.constraints.default,
|
||||||
|
};
|
||||||
|
if (account.emailVerified) {
|
||||||
|
this.log.debug({ email: account.email }, "verified email detected; including in context");
|
||||||
|
context.email = account.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraints$ = policies$.pipe(
|
||||||
|
map((policies) => profile.constraints.create(policies, context)),
|
||||||
|
tap(() => this.log.debug("constraints created")),
|
||||||
|
);
|
||||||
|
|
||||||
|
return constraints$;
|
||||||
|
}),
|
||||||
|
// complete policy emissions otherwise `switchMap` holds `constraints$`
|
||||||
|
// open indefinitely
|
||||||
|
takeUntil(anyComplete(account$)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return constraints$;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user