enh: admin sync

This commit is contained in:
Ben Allfree 2023-11-14 17:13:56 -08:00
parent a4aeede347
commit 4751d0c16e
13 changed files with 197 additions and 7 deletions

View File

@ -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 @@
<Maintenance />
<VersionChange />
<AdminSync />
</div>

View File

@ -0,0 +1,40 @@
<script lang="ts">
import Card from '$components/cards/Card.svelte'
import CardHeader from '$components/cards/CardHeader.svelte'
import { DOCS_URL } from '$src/env'
import { client } from '$src/pocketbase-client'
import { instance } from '../store'
const { updateInstance } = client()
$: ({ id, syncAdmin } = $instance)
const handleChange = (e: Event) => {
const target = e.target as HTMLInputElement
const isChecked = target.checked
// Update the database with the new value
updateInstance({ id, fields: { syncAdmin: isChecked } }).then(() => 'saved')
}
</script>
<Card>
<CardHeader documentation={DOCS_URL(`/usage/admin-sync`)}>
Admin Sync
</CardHeader>
<p class="mb-8">
Your instance will have an admin login that matches your pockethost.io
login.
</p>
<label class="label cursor-pointer justify-center gap-4">
<span class="label-text">Admin Sync</span>
<input
type="checkbox"
class="toggle toggle-warning"
checked={!!syncAdmin}
on:change={handleChange}
/>
</label>
</Card>

View File

@ -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.

View File

@ -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}`

View File

@ -0,0 +1,27 @@
/// <reference path="../pb_data/types.d.ts" />
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)
})

View File

@ -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()

View File

@ -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)

View File

@ -0,0 +1,29 @@
/// <reference path="../types/types.d.ts" />
/*
{
"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(),
)

View File

@ -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({

View File

@ -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<GetUserTokenPayload, GetUserTokenResult>(
RestCommands.UserToken,
RestMethods.Get,
GetUserTokenPayloadSchema,
)
const api = {
client,
url,
createFirstAdmin,
adminAuthViaEmail,
getUserTokenInfo,
...instanceApi,
}

View File

@ -27,6 +27,7 @@ export type InstanceFields = BaseFields & {
version: VersionId
secrets: InstanceSecretCollection | null
maintenance: boolean
syncAdmin: boolean
}
export type WithUser = {

View File

@ -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<GetUserTokenPayload> = {
type: 'object',
properties: {
id: { type: 'string' },
},
required: ['id'],
additionalProperties: false,
}

View File

@ -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'