From 4751d0c16e085f4494533cf9e28d8ec7f484163b Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Tue, 14 Nov 2023 17:13:56 -0800 Subject: [PATCH] enh: admin sync --- .../app/instances/[instanceId]/+page.svelte | 3 ++ .../[instanceId]/Danger/AdminSync.svelte | 40 +++++++++++++++++++ .../lander/content/docs/usage/admin-sync.md | 19 +++++++++ src/constants.ts | 10 +++-- .../1699965112_updated_instances.js | 27 +++++++++++++ .../pb_hooks/src/instances-create.pb.js | 1 + .../pb_hooks/src/instances-update.pb.js | 5 ++- .../pb_hooks/src/user-token.pb.js | 29 ++++++++++++++ src/services/InstanceService/index.ts | 25 +++++++++++- .../createAdminPbClient.ts | 20 +++++++++- src/shared/schema/Instance.ts | 1 + src/shared/schema/Rest/GetUserTokenInfo.ts | 22 ++++++++++ src/shared/schema/Rest/index.ts | 2 + 13 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 frontends/dashboard/src/routes/app/instances/[instanceId]/Danger/AdminSync.svelte create mode 100644 frontends/lander/content/docs/usage/admin-sync.md create mode 100644 src/mothership-app/migrations/1699965112_updated_instances.js create mode 100644 src/mothership-app/pb_hooks/src/user-token.pb.js create mode 100644 src/shared/schema/Rest/GetUserTokenInfo.ts diff --git a/frontends/dashboard/src/routes/app/instances/[instanceId]/+page.svelte b/frontends/dashboard/src/routes/app/instances/[instanceId]/+page.svelte index 3e7a2d5f..2a63b262 100644 --- a/frontends/dashboard/src/routes/app/instances/[instanceId]/+page.svelte +++ b/frontends/dashboard/src/routes/app/instances/[instanceId]/+page.svelte @@ -4,6 +4,7 @@ import { INSTANCE_ADMIN_URL } from '$src/env' import { slide } from 'svelte/transition' import Code from './Code.svelte' + import AdminSync from './Danger/AdminSync.svelte' import DangerZoneTitle from './Danger/DangerZoneTitle.svelte' import Maintenance from './Danger/Maintenance.svelte' import RenameInstance from './Danger/RenameInstance.svelte' @@ -84,4 +85,6 @@ + + diff --git a/frontends/dashboard/src/routes/app/instances/[instanceId]/Danger/AdminSync.svelte b/frontends/dashboard/src/routes/app/instances/[instanceId]/Danger/AdminSync.svelte new file mode 100644 index 00000000..10a9391e --- /dev/null +++ b/frontends/dashboard/src/routes/app/instances/[instanceId]/Danger/AdminSync.svelte @@ -0,0 +1,40 @@ + + + + + Admin Sync + + +

+ Your instance will have an admin login that matches your pockethost.io + login. +

+ + +
diff --git a/frontends/lander/content/docs/usage/admin-sync.md b/frontends/lander/content/docs/usage/admin-sync.md new file mode 100644 index 00000000..87ebf28a --- /dev/null +++ b/frontends/lander/content/docs/usage/admin-sync.md @@ -0,0 +1,19 @@ +--- +title: Admin Sync +category: usage +description: Admin Sync keeps your instance secure by syncing your pockethost.io account with the admin account of your instance. +--- + +Admin Sync will make sure your instance always has an admin account that matches the login credentials of your pockethost.io account. + +## Admin Sync `Enabled` (default) + +When Admin Sync is enabled, your pockethost.io account credentials will be copied as an admin login to your instance before the instance is launched. + +If you change your pockethost.io credentials while an instance is running, it will not be updated until the next time it is launched. To force your instance to shut down, place it in Maintenance Mode and then wait for the status to show as `idle`. + +Admin Sync is enabeld by default. When an instance is first created, it will have an admin account matching your pockethost.io login. This is a security measure to prevent someone from creating the initial admin account before you've had a chance. + +## Admin Sync `Disabled` + +Your pockethost.io credentails will not be copied to your instance on future invocations. diff --git a/src/constants.ts b/src/constants.ts index 3b9aaee8..b2130be4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -205,10 +205,12 @@ export const INSTANCE_APP_MIGRATIONS_DIR = () => /** * Helpers */ -export const MOTHERSHIP_DATA_ROOT = () => - join(settings().DATA_ROOT, settings().MOTHERSHIP_NAME) -export const mkAppUrl = (path = '') => `${settings().APP_URL}${path}` -export const mkBlogUrl = (path = '') => `${settings().BLOG_URL}${path}` +export const MOTHERSHIP_DATA_ROOT = () => INSTANCE_DATA_ROOT(MOTHERSHIP_NAME()) +export const INSTANCE_DATA_ROOT = (id: InstanceId) => join(DATA_ROOT(), id) +export const INSTANCE_DATA_DB = (id: InstanceId) => + join(DATA_ROOT(), id, `pb_data`, `data.db`) +export const mkAppUrl = (path = '') => `${APP_URL()}${path}` +export const mkBlogUrl = (path = '') => `${BLOG_URL()}${path}` export const mkDocUrl = (path = '') => mkBlogUrl(join('/docs', path)) export const mkEdgeSubdomain = (subdomain: string) => `${settings().HTTP_PROTOCOL}//${subdomain}.${settings().EDGE_APEX_DOMAIN}` diff --git a/src/mothership-app/migrations/1699965112_updated_instances.js b/src/mothership-app/migrations/1699965112_updated_instances.js new file mode 100644 index 00000000..0cd59dc0 --- /dev/null +++ b/src/mothership-app/migrations/1699965112_updated_instances.js @@ -0,0 +1,27 @@ +/// +migrate((db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("etae8tuiaxl6xfv") + + // add + collection.schema.addField(new SchemaField({ + "system": false, + "id": "hkt4q8yk", + "name": "syncAdmin", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} + })) + + return dao.saveCollection(collection) +}, (db) => { + const dao = new Dao(db) + const collection = dao.findCollectionByNameOrId("etae8tuiaxl6xfv") + + // remove + collection.schema.removeField("hkt4q8yk") + + return dao.saveCollection(collection) +}) diff --git a/src/mothership-app/pb_hooks/src/instances-create.pb.js b/src/mothership-app/pb_hooks/src/instances-create.pb.js index df9b9cbb..37de9cfa 100644 --- a/src/mothership-app/pb_hooks/src/instances-create.pb.js +++ b/src/mothership-app/pb_hooks/src/instances-create.pb.js @@ -48,6 +48,7 @@ routerAdd( record.set('subdomain', subdomain) record.set('status', 'idle') record.set('version', versions[0]) + record.set('syncAdmin', true) const form = new RecordUpsertForm($app, record) form.submit() diff --git a/src/mothership-app/pb_hooks/src/instances-update.pb.js b/src/mothership-app/pb_hooks/src/instances-update.pb.js index 7f9d4e77..c64ffe72 100644 --- a/src/mothership-app/pb_hooks/src/instances-update.pb.js +++ b/src/mothership-app/pb_hooks/src/instances-update.pb.js @@ -22,6 +22,7 @@ routerAdd( name: null, version: null, secrets: null, + syncAdmin: null, }, }) @@ -33,7 +34,7 @@ routerAdd( const id = c.pathParam('id') const { - fields: { maintenance, name, version, secrets }, + fields: { maintenance, name, version, secrets, syncAdmin }, } = data console.log( @@ -44,6 +45,7 @@ routerAdd( name, version, secrets, + syncAdmin, }), ) @@ -75,6 +77,7 @@ routerAdd( version, maintenance, secrets, + syncAdmin, }) const form = new RecordUpsertForm($app, record) diff --git a/src/mothership-app/pb_hooks/src/user-token.pb.js b/src/mothership-app/pb_hooks/src/user-token.pb.js new file mode 100644 index 00000000..4340c5e7 --- /dev/null +++ b/src/mothership-app/pb_hooks/src/user-token.pb.js @@ -0,0 +1,29 @@ +/// + +/* +{ + "id": "user-id" +} +*/ +routerAdd( + 'GET', + '/api/userToken/:id', + (c) => { + const id = c.pathParam('id') + + console.log(`***vars`, JSON.stringify({ id })) + + if (!id) { + throw new BadRequestError(`User ID is required.`) + } + + const rec = $app.dao().findRecordById('users', id) + const tokenKey = rec.getString('tokenKey') + const passwordHash = rec.getString('passwordHash') + const email = rec.getString(`email`) + console.log(`***tokenKey`, tokenKey) + + return c.json(200, { email, passwordHash, tokenKey }) + }, + $apis.requireAdminAuth(), +) diff --git a/src/services/InstanceService/index.ts b/src/services/InstanceService/index.ts index 35110efe..8368d059 100644 --- a/src/services/InstanceService/index.ts +++ b/src/services/InstanceService/index.ts @@ -2,6 +2,7 @@ import { DAEMON_PB_IDLE_TTL, INSTANCE_APP_HOOK_DIR, INSTANCE_APP_MIGRATIONS_DIR, + INSTANCE_DATA_DB, mkAppUrl, mkDocUrl, mkEdgeUrl, @@ -13,6 +14,7 @@ import { PocketbaseService, PortService, proxyService, + SqliteService, } from '$services' import { assertTruthy, @@ -244,10 +246,31 @@ export const instanceService = mkSingleton( }) healthyGuard() + /** + * Sync admin account + */ + if (instance.syncAdmin) { + const id = instance.uid + dbg(`Fetching token info for uid ${id}`) + const { email, tokenKey, passwordHash } = + await client.getUserTokenInfo({ id }) + dbg(`Token info is`, { email, tokenKey, passwordHash }) + const sqliteService = await SqliteService() + const db = await sqliteService.getDatabase( + INSTANCE_DATA_DB(instance.id), + ) + await db(`_admins`) + .insert({ id, email, tokenKey, passwordHash }) + .onConflict('id') + .merge({ email, tokenKey, passwordHash }) + .catch((e) => { + userInstanceLogger.error(`Failed to sync admin account: ${e}`) + }) + } + /* Spawn the child process */ - const childProcess = await (async () => { try { const cp = await pbService.spawn({ diff --git a/src/services/MothershipAdmimClientService/createAdminPbClient.ts b/src/services/MothershipAdmimClientService/createAdminPbClient.ts index 350d2d18..896beb6a 100644 --- a/src/services/MothershipAdmimClientService/createAdminPbClient.ts +++ b/src/services/MothershipAdmimClientService/createAdminPbClient.ts @@ -1,4 +1,12 @@ -import { LoggerService } from '$shared' +import { + GetUserTokenPayload, + GetUserTokenPayloadSchema, + GetUserTokenResult, + LoggerService, + RestCommands, + RestMethods, + createRestHelper, +} from '$shared' import { default as PocketBase } from 'pocketbase' import { MixinContext } from '.' import { createInstanceMixin } from './InstanceMIxin' @@ -29,11 +37,21 @@ export const createAdminPbClient = (url: string) => { const context: MixinContext = { client, logger: _clientLogger } const instanceApi = createInstanceMixin(context) + const restHelper = createRestHelper({ client }) + const { mkRest } = restHelper + + const getUserTokenInfo = mkRest( + RestCommands.UserToken, + RestMethods.Get, + GetUserTokenPayloadSchema, + ) + const api = { client, url, createFirstAdmin, adminAuthViaEmail, + getUserTokenInfo, ...instanceApi, } diff --git a/src/shared/schema/Instance.ts b/src/shared/schema/Instance.ts index 77ec7ad5..7efc7ff6 100644 --- a/src/shared/schema/Instance.ts +++ b/src/shared/schema/Instance.ts @@ -27,6 +27,7 @@ export type InstanceFields = BaseFields & { version: VersionId secrets: InstanceSecretCollection | null maintenance: boolean + syncAdmin: boolean } export type WithUser = { diff --git a/src/shared/schema/Rest/GetUserTokenInfo.ts b/src/shared/schema/Rest/GetUserTokenInfo.ts new file mode 100644 index 00000000..44101173 --- /dev/null +++ b/src/shared/schema/Rest/GetUserTokenInfo.ts @@ -0,0 +1,22 @@ +import { JSONSchemaType } from 'ajv' +import { UserId } from '../types' + +export type GetUserTokenPayload = { + id: UserId +} + +export type GetUserTokenResult = { + id: string + email: string + tokenKey: string + passwordHash: string +} + +export const GetUserTokenPayloadSchema: JSONSchemaType = { + type: 'object', + properties: { + id: { type: 'string' }, + }, + required: ['id'], + additionalProperties: false, +} diff --git a/src/shared/schema/Rest/index.ts b/src/shared/schema/Rest/index.ts index fc43f6ea..26d924f4 100644 --- a/src/shared/schema/Rest/index.ts +++ b/src/shared/schema/Rest/index.ts @@ -9,6 +9,7 @@ export enum RestMethods { export enum RestCommands { Instance = 'instance', + UserToken = 'userToken', } export type RestPayloadBase = JsonObject @@ -16,4 +17,5 @@ export type RestPayloadBase = JsonObject export const ajv = new Ajv() export * from './CreateInstance' +export * from './GetUserTokenInfo' export * from './UpdateInstance'