Skip to content

Commit

Permalink
Delete obsolete LocalAccounts on App startup (#356)
Browse files Browse the repository at this point in the history
* feat: add logic to start of runtime

* feat: add backbone route

* refactor: rename checkIdentityDeletionForUsername

* feat: add checkIdentityDeletionForUsername use case

* feat: get username in transport

* refactor: rename to checkDeletionOfIdentity

* test: identitycontroller

* fix: rename consistently

* test: add runtime test

* feat: continue startApp

* feat: delete accounts if identities are deleted

* feat: bump backbone

* fix: provide config for updated backbone

* feat: add IdentityDeletionInfo object

* test: add transport test for passed grace period

* refactor: errors

* Revert "feat: add IdentityDeletionInfo object"

This reverts commit 01813a6.

* test: add runtime test for expired grace period

* refactor: naming

* refactor: rename startAccounts

* feat: configure gracePeriod initializing IdentityDeletionProcess

* feat: allow to manually run deletion job on backbone

* test: run deletion job in skipped test

* test: deleted Identity

* feat: log error

* refactor: backbone response can be null

* feat: check for deleted Identities; wip

* fix: return early for error other than noAuthGrant

* feat: rename offoardDevice

* test: accounts of deleted identities are remove on app startup

* refactor: rename offboardDevice on AccountService

* feat: add deleteAccount to AccountServices

* fix: app-runtime tests

* refactor: rename offboardAccount

* chore: simplify

* chore: simplify

* refactor: rename checkIfIdentityIsDeleted

* test: make tests independent

* test: offboard account of identity with ongoing grace period

* test: adjust runDeletionJob

* chore: package-lock

* test: rename

* fix: make more tests independent

---------

Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com>
Co-authored-by: Julian König <julian.koenig@js-soft.com>
  • Loading branch information
3 people authored Dec 13, 2024
1 parent 59d5579 commit e1bd4c7
Show file tree
Hide file tree
Showing 14 changed files with 289 additions and 11 deletions.
43 changes: 43 additions & 0 deletions packages/app-runtime/src/AppRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,13 +293,56 @@ export class AppRuntime extends Runtime<AppConfig> {
return Promise.resolve();
}

public override async start(): Promise<void> {
await super.start();

await this.startAccounts();
}

public override async stop(): Promise<void> {
const logError = (e: any) => this.logger.error(e);

await super.stop().catch(logError);
await this.lokiConnection.close().catch(logError);
}

private async startAccounts(): Promise<void> {
const accounts = await this._multiAccountController.getAccounts();

for (const account of accounts) {
const session = await this.selectAccount(account.id.toString());

session.accountController.authenticator.clear();
try {
await session.accountController.authenticator.getToken();
continue;
} catch (error) {
this.logger.error(error);

if (!(typeof error === "object" && error !== null && "code" in error)) {
continue;
}

if (!(error.code === "error.transport.request.noAuthGrant")) continue;
}

const checkDeletionResult = await session.transportServices.account.checkIfIdentityIsDeleted();

if (checkDeletionResult.isError) {
this.logger.error(checkDeletionResult.error);
continue;
}

if (checkDeletionResult.value.isDeleted) {
await this._multiAccountController.deleteAccount(account.id);
continue;
}

const syncResult = await session.transportServices.account.syncDatawallet();
if (syncResult.isError) this.logger.error(syncResult.error);
}
}

private translationProvider: INativeTranslationProvider = {
translate: (key: string) => Promise.resolve(Result.ok(key))
};
Expand Down
4 changes: 4 additions & 0 deletions packages/app-runtime/src/multiAccount/AccountServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export class AccountServices {
return LocalAccountMapper.toLocalAccountDTO(localAccount);
}

public async offboardAccount(id: string): Promise<void> {
await this.multiAccountController.offboardAccount(CoreId.from(id));
}

public async deleteAccount(id: string): Promise<void> {
await this.multiAccountController.deleteAccount(CoreId.from(id));
}
Expand Down
10 changes: 7 additions & 3 deletions packages/app-runtime/src/multiAccount/MultiAccountController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,17 @@ export class MultiAccountController {
return [localAccount, accountController];
}

public async deleteAccount(id: CoreId): Promise<void> {
const [localAccount, accountController] = await this.selectAccount(id);
public async offboardAccount(id: CoreId): Promise<void> {
const [_, accountController] = await this.selectAccount(id);
await accountController.unregisterPushNotificationToken();
await accountController.activeDevice.markAsOffboarded();
await accountController.close();

delete this._openAccounts[localAccount.id.toString()];
await this.deleteAccount(id);
}

public async deleteAccount(id: CoreId): Promise<void> {
delete this._openAccounts[id.toString()];

await this.databaseConnection.deleteDatabase(`acc-${id.toString()}`);
await this._localAccounts.delete({ id: id.toString() });
Expand Down
28 changes: 28 additions & 0 deletions packages/app-runtime/test/lib/TestUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import {
SyncEverythingResponse
} from "@nmshd/runtime";
import { IConfigOverwrite, TransportLoggerFactory } from "@nmshd/transport";
import fs from "fs";
import path from "path";
import { GenericContainer, Wait } from "testcontainers";
import { LogLevel } from "typescript-logging";
import { AppConfig, AppRuntime, IUIBridge, LocalAccountDTO, LocalAccountSession, createAppConfig as runtime_createAppConfig } from "../../src";
import { FakeUIBridge } from "./FakeUIBridge";
Expand Down Expand Up @@ -262,4 +265,29 @@ export class TestUtil {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
expect(result.isSuccess, `${result.error?.code} | ${result.error?.message}`).toBe(true);
}

public static async runDeletionJob(): Promise<void> {
const backboneVersion = this.getBackboneVersion();
const appsettingsOverrideLocation = process.env.APPSETTINGS_OVERRIDE_LOCATION ?? `${__dirname}/../../../../.dev/appsettings.override.json`;

await new GenericContainer(`ghcr.io/nmshd/backbone-identity-deletion-jobs:${backboneVersion}`)
.withWaitStrategy(Wait.forOneShotStartup())
.withCommand(["--Worker", "ActualDeletionWorker"])
.withNetworkMode("backbone")
.withCopyFilesToContainer([{ source: appsettingsOverrideLocation, target: "/app/appsettings.override.json" }])
.start();
}

private static getBackboneVersion() {
if (process.env.BACKBONE_VERSION) return process.env.BACKBONE_VERSION;

const envFile = fs.readFileSync(path.resolve(`${__dirname}/../../../../.dev/compose.backbone.env`));
const env = envFile
.toString()
.split("\n")
.map((line) => line.split("="))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as Record<string, string>);

return env["BACKBONE_VERSION"];
}
}
40 changes: 35 additions & 5 deletions packages/app-runtime/test/runtime/Offboarding.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { IdentityDeletionProcessStatus } from "@nmshd/runtime";
import { AppRuntime, AppRuntimeServices } from "../../src";
import { TestUtil } from "../lib";

describe("Offboarding", function () {
let runtime: AppRuntime;

let services1: AppRuntimeServices;
let services2: AppRuntimeServices;
let localAccount2Id: string;
let device2Id: string;

beforeAll(async function () {
beforeEach(async function () {
// as we can't pop up multiple runtimes we have to allow multiple accounts with
// the same address to test offboarding
const configOverride = { allowMultipleAccountsWithSameAddress: true };
Expand All @@ -25,18 +27,46 @@ describe("Offboarding", function () {

const localAccount2 = await runtime.accountServices.onboardAccount(onboardingInfoResult.value);
localAccount2Id = localAccount2.id;
const services2 = await runtime.getServices(localAccount2.id);
services2 = await runtime.getServices(localAccount2.id);

await services2.transportServices.account.syncDatawallet();
await services1.transportServices.account.syncDatawallet();
});

afterAll(async function () {
afterEach(async function () {
await runtime.stop();
});

test("delete account", async function () {
await runtime.accountServices.deleteAccount(localAccount2Id);
test("offboard Account for active Identity", async function () {
await runtime.accountServices.offboardAccount(localAccount2Id);
await services1.transportServices.account.syncDatawallet();

const accounts = await runtime.accountServices.getAccounts();
expect(accounts).toHaveLength(1);

const devicesResult = await services1.transportServices.devices.getDevices();
const filteredDevice = devicesResult.value.find((d) => d.id === device2Id);

expect(filteredDevice).toBeDefined();
expect(filteredDevice!.isOffboarded).toBe(true);

const deviceResult = await services1.transportServices.devices.getDevice({ id: device2Id });
const device = deviceResult.value;

expect(device.isOffboarded).toBe(true);

await expect(runtime.getServices(localAccount2Id)).rejects.toThrow("error.transport.recordNotFound");
await expect(runtime.selectAccount(localAccount2Id)).rejects.toThrow("error.transport.recordNotFound");
});

test("offboard Account for Identity within grace period of IdentityDeletionProcess", async function () {
await services1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess();
await services2.transportServices.account.syncDatawallet();

const identityDeletionProcessOnSecondAccount = (await services2.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess()).value;
expect(identityDeletionProcessOnSecondAccount.status).toStrictEqual(IdentityDeletionProcessStatus.Approved);

await runtime.accountServices.offboardAccount(localAccount2Id);
await services1.transportServices.account.syncDatawallet();

const accounts = await runtime.accountServices.getAccounts();
Expand Down
39 changes: 38 additions & 1 deletion packages/app-runtime/test/runtime/Startup.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AppRuntime, LocalAccountDTO } from "../../src";
import { AppRuntime, LocalAccountDTO, LocalAccountSession } from "../../src";
import { EventListener, TestUtil } from "../lib";

describe("Runtime Startup", function () {
Expand Down Expand Up @@ -64,3 +64,40 @@ describe("Runtime Startup", function () {
expect(selectedAccount.account.id.toString()).toBe(localAccount.id.toString());
});
});

describe("Start Accounts", function () {
let runtime: AppRuntime;
let session: LocalAccountSession;

beforeAll(async function () {
runtime = await TestUtil.createRuntime();
await runtime.start();
});

beforeEach(async function () {
const accounts = await TestUtil.provideAccounts(runtime, 1);
session = await runtime.selectAccount(accounts[0].id);
});

afterAll(async () => await runtime.stop());

test("should not delete Account running startAccounts for an active Identity", async function () {
await runtime["startAccounts"]();
await expect(runtime.selectAccount(session.account.id)).resolves.not.toThrow();
});

test("should delete Account running startAccounts for an Identity with expired grace period", async function () {
await session.transportServices.identityDeletionProcesses["initiateIdentityDeletionProcessUseCase"]["identityDeletionProcessController"].initiateIdentityDeletionProcess(0);

await runtime["startAccounts"]();
await expect(runtime.selectAccount(session.account.id)).rejects.toThrow("error.transport.recordNotFound");
});

test("should delete Account running startAccounts for a deleted Identity", async function () {
await session.transportServices.identityDeletionProcesses["initiateIdentityDeletionProcessUseCase"]["identityDeletionProcessController"].initiateIdentityDeletionProcess(0);
await TestUtil.runDeletionJob();

await runtime["startAccounts"]();
await expect(runtime.selectAccount(session.account.id)).rejects.toThrow("error.transport.recordNotFound");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { ApplicationError, Result } from "@js-soft/ts-utils";
import { Inject } from "@nmshd/typescript-ioc";
import { DeviceDTO } from "../../../types";
import {
CheckIfIdentityIsDeletedResponse,
CheckIfIdentityIsDeletedUseCase,
DisableAutoSyncUseCase,
EnableAutoSyncUseCase,
GetDeviceInfoUseCase,
Expand Down Expand Up @@ -32,7 +34,8 @@ export class AccountFacade {
@Inject private readonly getSyncInfoUseCase: GetSyncInfoUseCase,
@Inject private readonly disableAutoSyncUseCase: DisableAutoSyncUseCase,
@Inject private readonly enableAutoSyncUseCase: EnableAutoSyncUseCase,
@Inject private readonly loadItemFromTruncatedReferenceUseCase: LoadItemFromTruncatedReferenceUseCase
@Inject private readonly loadItemFromTruncatedReferenceUseCase: LoadItemFromTruncatedReferenceUseCase,
@Inject private readonly checkIfIdentityIsDeletedUseCase: CheckIfIdentityIsDeletedUseCase
) {}

public async getIdentityInfo(): Promise<Result<GetIdentityInfoResponse, ApplicationError>> {
Expand Down Expand Up @@ -74,4 +77,8 @@ export class AccountFacade {
public async loadItemFromTruncatedReference(request: LoadItemFromTruncatedReferenceRequest): Promise<Result<LoadItemFromTruncatedReferenceResponse, ApplicationError>> {
return await this.loadItemFromTruncatedReferenceUseCase.execute(request);
}

public async checkIfIdentityIsDeleted(): Promise<Result<CheckIfIdentityIsDeletedResponse, ApplicationError>> {
return await this.checkIfIdentityIsDeletedUseCase.execute();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Result } from "@js-soft/ts-utils";
import { IdentityController } from "@nmshd/transport";
import { Inject } from "@nmshd/typescript-ioc";
import { UseCase } from "../../common";

export interface CheckIfIdentityIsDeletedResponse {
isDeleted: boolean;
deletionDate?: string;
}

export class CheckIfIdentityIsDeletedUseCase extends UseCase<void, CheckIfIdentityIsDeletedResponse> {
public constructor(@Inject private readonly identityController: IdentityController) {
super();
}

protected async executeInternal(): Promise<Result<CheckIfIdentityIsDeletedResponse>> {
const result = await this.identityController.checkIfIdentityIsDeleted();

if (result.isError) return Result.fail(result.error);

return Result.ok(result.value);
}
}
1 change: 1 addition & 0 deletions packages/runtime/src/useCases/transport/account/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./CheckIfIdentityIsDeleted";
export * from "./DisableAutoSync";
export * from "./EnableAutoSync";
export * from "./GetDeviceInfo";
Expand Down
19 changes: 19 additions & 0 deletions packages/runtime/test/transport/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,22 @@ describe("Un-/RegisterPushNotificationToken", () => {
expect(result).toBeSuccessful();
});
});

describe("CheckIfIdentityIsDeleted", () => {
test("check deletion of Identity that is not deleted", async () => {
const result = await sTransportServices.account.checkIfIdentityIsDeleted();
expect(result.isSuccess).toBe(true);
expect(result.value.isDeleted).toBe(false);
expect(result.value.deletionDate).toBeUndefined();
});

test("check deletion of Identity that has IdentityDeletionProcess with expired grace period", async () => {
const identityDeletionProcess =
await sTransportServices.identityDeletionProcesses["initiateIdentityDeletionProcessUseCase"]["identityDeletionProcessController"].initiateIdentityDeletionProcess(0);

const result = await sTransportServices.account.checkIfIdentityIsDeleted();
expect(result.isSuccess).toBe(true);
expect(result.value.isDeleted).toBe(true);
expect(result.value.deletionDate).toBe(identityDeletionProcess.cache!.gracePeriodEndsAt!.toString());
});
});
24 changes: 23 additions & 1 deletion packages/transport/src/modules/accounts/IdentityController.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { log } from "@js-soft/ts-utils";
import { log, Result } from "@js-soft/ts-utils";
import { CoreAddress } from "@nmshd/core-types";
import { CoreBuffer, CryptoSignature, CryptoSignaturePrivateKey, CryptoSignaturePublicKey } from "@nmshd/crypto";
import { ControllerName, CoreCrypto, TransportController, TransportCoreErrors } from "../../core";
import { AccountController } from "../accounts/AccountController";
import { DeviceSecretType } from "../devices/DeviceSecretController";
import { IdentityClient } from "./backbone/IdentityClient";
import { Identity } from "./data/Identity";

export class IdentityController extends TransportController {
public identityClient: IdentityClient;

public get address(): CoreAddress {
return this._identity.address;
}
Expand All @@ -22,6 +25,8 @@ export class IdentityController extends TransportController {

public constructor(parent: AccountController) {
super(ControllerName.Identity, parent);

this.identityClient = new IdentityClient(this.config, this.transport.correlator);
}

@log()
Expand Down Expand Up @@ -57,4 +62,21 @@ export class IdentityController extends TransportController {
const valid = await CoreCrypto.verify(content, signature, this.publicKey);
return valid;
}

public async checkIfIdentityIsDeleted(): Promise<
Result<{
isDeleted: boolean;
deletionDate?: string;
}>
> {
const currentDeviceCredentials = await this.parent.activeDevice.getCredentials();
const identityDeletionResult = await this.identityClient.checkIfIdentityIsDeleted(currentDeviceCredentials.username);

if (identityDeletionResult.isError) return Result.fail(identityDeletionResult.error);

return Result.ok({
isDeleted: identityDeletionResult.value.isDeleted,
deletionDate: identityDeletionResult.value.deletionDate ?? undefined
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface BackboneCheckIfIdentityIsDeletedResponse {
isDeleted: boolean;
deletionDate: string | null;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RESTClient, RESTClientLogDirective } from "../../../core";
import { ClientResult } from "../../../core/backbone/ClientResult";
import { BackboneCheckIfIdentityIsDeletedResponse } from "./BackboneCheckIfIdentityIsDeleted";
import { BackbonePostIdentityRequest, BackbonePostIdentityResponse } from "./BackbonePostIdentity";

export class IdentityClient extends RESTClient {
Expand All @@ -8,4 +9,8 @@ export class IdentityClient extends RESTClient {
public async createIdentity(value: BackbonePostIdentityRequest): Promise<ClientResult<BackbonePostIdentityResponse>> {
return await this.post<BackbonePostIdentityResponse>("/api/v1/Identities", value);
}

public async checkIfIdentityIsDeleted(username: string): Promise<ClientResult<BackboneCheckIfIdentityIsDeletedResponse>> {
return await this.get<BackboneCheckIfIdentityIsDeletedResponse>(`/api/v1/Identities/IsDeleted?username=${username}`);
}
}
Loading

0 comments on commit e1bd4c7

Please sign in to comment.