mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-06 16:16:26 +00:00
Add hosted mobile token revoke helper
This commit is contained in:
parent
57cc212f34
commit
ce4e418d84
5 changed files with 380 additions and 70 deletions
|
|
@ -7,11 +7,9 @@ import path from 'node:path';
|
|||
import process from 'node:process';
|
||||
|
||||
import {
|
||||
resolveHostedTenantRootDataDir,
|
||||
restartHostedTenantRuntime,
|
||||
runRemote,
|
||||
shellQuote,
|
||||
} from './hosted-tenant-runtime.mjs';
|
||||
import { createHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs';
|
||||
|
||||
const DEFAULT_CLOUD_HOST = 'root@pulse-cloud';
|
||||
const DEFAULT_CONTROL_PLANE_URL = 'https://cloud.pulserelay.pro';
|
||||
|
|
@ -106,28 +104,6 @@ function runText(command, args, options = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
function buildLocalHelper(tempDir) {
|
||||
const binaryPath = path.join(tempDir, 'relay-mobile-token-helper');
|
||||
execFileSync('go', [
|
||||
'build',
|
||||
'-buildvcs=false',
|
||||
'-o',
|
||||
binaryPath,
|
||||
'./tests/integration/scripts/relay-mobile-token-helper.go',
|
||||
], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: '0',
|
||||
GOARCH: 'amd64',
|
||||
GOOS: 'linux',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
|
||||
const parsed = new URL(controlPlaneUrl);
|
||||
parsed.hostname = `${tenantId}.${parsed.hostname}`;
|
||||
|
|
@ -137,36 +113,6 @@ function deriveTenantBaseUrl(controlPlaneUrl, tenantId) {
|
|||
return parsed.toString().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function createHostedRelayMobileToken({ cloudHost, tenantId, tempDir }) {
|
||||
const localBinaryPath = buildLocalHelper(tempDir);
|
||||
const remoteBinaryPath = `/tmp/relay-mobile-token-helper-${process.pid}-${Date.now()}`;
|
||||
const tenantDataDir = resolveHostedTenantRootDataDir(tenantId);
|
||||
|
||||
try {
|
||||
execFileSync('scp', [localBinaryPath, `${cloudHost}:${remoteBinaryPath}`], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
runRemote(cloudHost, `chmod +x ${shellQuote(remoteBinaryPath)}`);
|
||||
|
||||
const output = runRemote(cloudHost, [
|
||||
shellQuote(remoteBinaryPath),
|
||||
'create',
|
||||
'--data-dir',
|
||||
shellQuote(tenantDataDir),
|
||||
'--org-id',
|
||||
shellQuote(tenantId),
|
||||
].join(' '));
|
||||
|
||||
return JSON.parse(output);
|
||||
} finally {
|
||||
try {
|
||||
runRemote(cloudHost, `rm -f ${shellQuote(remoteBinaryPath)}`);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function curlJson(args) {
|
||||
return JSON.parse(runText('curl', args));
|
||||
}
|
||||
|
|
|
|||
95
tests/integration/scripts/delete-hosted-mobile-token.mjs
Normal file
95
tests/integration/scripts/delete-hosted-mobile-token.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { deleteHostedRelayMobileToken } from './hosted-mobile-token-runtime.mjs';
|
||||
import { restartHostedTenantRuntime } from './hosted-tenant-runtime.mjs';
|
||||
|
||||
const DEFAULT_CLOUD_HOST = 'root@pulse-cloud';
|
||||
|
||||
function usage(message) {
|
||||
if (message) {
|
||||
console.error(`error: ${message}`);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
console.error(
|
||||
'usage: node ./tests/integration/scripts/delete-hosted-mobile-token.mjs --tenant-id <id> [--token-id <id>] [--token <raw-token>] [--cloud-host <user@host>]',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const parsed = {
|
||||
cloudHost: DEFAULT_CLOUD_HOST,
|
||||
tenantId: '',
|
||||
token: '',
|
||||
tokenId: '',
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
switch (arg) {
|
||||
case '--tenant-id':
|
||||
parsed.tenantId = argv[index + 1] ?? usage('missing value for --tenant-id');
|
||||
index += 1;
|
||||
break;
|
||||
case '--token-id':
|
||||
parsed.tokenId = argv[index + 1] ?? usage('missing value for --token-id');
|
||||
index += 1;
|
||||
break;
|
||||
case '--token':
|
||||
parsed.token = argv[index + 1] ?? usage('missing value for --token');
|
||||
index += 1;
|
||||
break;
|
||||
case '--cloud-host':
|
||||
parsed.cloudHost = argv[index + 1] ?? usage('missing value for --cloud-host');
|
||||
index += 1;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
usage();
|
||||
break;
|
||||
default:
|
||||
usage(`unsupported flag ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
parsed.tenantId = String(parsed.tenantId).trim();
|
||||
parsed.tokenId = String(parsed.tokenId).trim();
|
||||
parsed.token = String(parsed.token).trim();
|
||||
|
||||
if (!parsed.tenantId) {
|
||||
usage('--tenant-id is required');
|
||||
}
|
||||
if (!parsed.tokenId && !parsed.token) {
|
||||
usage('either --token-id or --token is required');
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-hosted-mobile-token-delete-'));
|
||||
|
||||
try {
|
||||
const result = deleteHostedRelayMobileToken({
|
||||
cloudHost: args.cloudHost,
|
||||
tenantId: args.tenantId,
|
||||
tempDir,
|
||||
token: args.token || null,
|
||||
tokenId: args.tokenId || null,
|
||||
});
|
||||
restartHostedTenantRuntime(args.cloudHost, args.tenantId);
|
||||
result.runtimeRestarted = true;
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
103
tests/integration/scripts/hosted-mobile-token-runtime.mjs
Normal file
103
tests/integration/scripts/hosted-mobile-token-runtime.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
resolveHostedTenantRootDataDir,
|
||||
runRemote,
|
||||
shellQuote,
|
||||
} from './hosted-tenant-runtime.mjs';
|
||||
|
||||
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||||
|
||||
function buildLocalHelper(tempDir) {
|
||||
const binaryPath = path.join(tempDir, 'relay-mobile-token-helper');
|
||||
execFileSync('go', [
|
||||
'build',
|
||||
'-buildvcs=false',
|
||||
'-o',
|
||||
binaryPath,
|
||||
'./tests/integration/scripts/relay-mobile-token-helper.go',
|
||||
], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: '0',
|
||||
GOARCH: 'amd64',
|
||||
GOOS: 'linux',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
function runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir }) {
|
||||
const localBinaryPath = buildLocalHelper(tempDir);
|
||||
const remoteBinaryPath = `/tmp/relay-mobile-token-helper-${process.pid}-${Date.now()}`;
|
||||
|
||||
try {
|
||||
execFileSync('scp', [localBinaryPath, `${cloudHost}:${remoteBinaryPath}`], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
runRemote(cloudHost, `chmod +x ${shellQuote(remoteBinaryPath)}`);
|
||||
|
||||
return runRemote(cloudHost, [
|
||||
shellQuote(remoteBinaryPath),
|
||||
...args.map((value) => shellQuote(value)),
|
||||
].join(' '));
|
||||
} finally {
|
||||
try {
|
||||
runRemote(cloudHost, `rm -f ${shellQuote(remoteBinaryPath)}`);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function createHostedRelayMobileToken({ cloudHost, issuedVia = null, tenantId, tempDir }) {
|
||||
const args = [
|
||||
'create',
|
||||
'--data-dir',
|
||||
resolveHostedTenantRootDataDir(tenantId),
|
||||
'--org-id',
|
||||
tenantId,
|
||||
];
|
||||
if (issuedVia) {
|
||||
args.push('--issued-via', issuedVia);
|
||||
}
|
||||
return JSON.parse(runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir }));
|
||||
}
|
||||
|
||||
export function deleteHostedRelayMobileToken({
|
||||
cloudHost,
|
||||
tenantId,
|
||||
tempDir,
|
||||
token = null,
|
||||
tokenId = null,
|
||||
}) {
|
||||
const args = [
|
||||
'delete',
|
||||
'--data-dir',
|
||||
resolveHostedTenantRootDataDir(tenantId),
|
||||
];
|
||||
if (tokenId) {
|
||||
args.push('--token-id', tokenId);
|
||||
}
|
||||
if (token) {
|
||||
args.push('--token', token);
|
||||
}
|
||||
return JSON.parse(runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir }));
|
||||
}
|
||||
|
||||
export function validateHostedRelayMobileToken({ cloudHost, tenantId, tempDir, token }) {
|
||||
const args = [
|
||||
'validate',
|
||||
'--data-dir',
|
||||
resolveHostedTenantRootDataDir(tenantId),
|
||||
'--token',
|
||||
token,
|
||||
];
|
||||
return JSON.parse(runHostedRelayMobileTokenHelper({ args, cloudHost, tempDir }));
|
||||
}
|
||||
|
|
@ -37,6 +37,13 @@ type validationResult struct {
|
|||
Record *helperTokenRecord `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
type deleteResult struct {
|
||||
Action string `json:"action"`
|
||||
DataDir string `json:"dataDir"`
|
||||
Deleted bool `json:"deleted"`
|
||||
Record *helperTokenRecord `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
type helperTokenRecord struct {
|
||||
ID string `json:"id"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
|
|
@ -56,6 +63,8 @@ func usage(message string) {
|
|||
}
|
||||
fmt.Fprintln(os.Stderr, "usage:")
|
||||
fmt.Fprintln(os.Stderr, " go run ./tests/integration/scripts/relay-mobile-token-helper.go create --data-dir <dir> --org-id <id> [options]")
|
||||
fmt.Fprintln(os.Stderr, " go run ./tests/integration/scripts/relay-mobile-token-helper.go delete --data-dir <dir> (--token-id <id> | --token <raw-token>)")
|
||||
fmt.Fprintln(os.Stderr, " go run ./tests/integration/scripts/relay-mobile-token-helper.go validate --data-dir <dir> --token <raw-token>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +96,60 @@ func pruneExistingProofTokens(tokens []config.APITokenRecord, orgID, issuedVia s
|
|||
return filtered, pruned
|
||||
}
|
||||
|
||||
func toHelperTokenRecord(record config.APITokenRecord) helperTokenRecord {
|
||||
return helperTokenRecord{
|
||||
ID: record.ID,
|
||||
Metadata: record.Metadata,
|
||||
Name: record.Name,
|
||||
OrgID: record.OrgID,
|
||||
Scopes: append([]string{}, record.Scopes...),
|
||||
}
|
||||
}
|
||||
|
||||
func findTokenRecord(tokens []config.APITokenRecord, tokenID, rawToken string) (*config.APITokenRecord, error) {
|
||||
scopedTokenID := strings.TrimSpace(tokenID)
|
||||
scopedRawToken := strings.TrimSpace(rawToken)
|
||||
|
||||
if scopedTokenID == "" && scopedRawToken == "" {
|
||||
return nil, fmt.Errorf("either token id or raw token is required")
|
||||
}
|
||||
|
||||
if scopedRawToken != "" {
|
||||
cfg := &config.Config{APITokens: tokens}
|
||||
record, ok := cfg.ValidateAPIToken(scopedRawToken)
|
||||
if !ok || record == nil {
|
||||
return nil, nil
|
||||
}
|
||||
scopedTokenID = strings.TrimSpace(record.ID)
|
||||
}
|
||||
|
||||
for idx := range tokens {
|
||||
if strings.TrimSpace(tokens[idx].ID) == scopedTokenID {
|
||||
return &tokens[idx], nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func deleteTokenRecord(tokens []config.APITokenRecord, tokenID string) ([]config.APITokenRecord, *config.APITokenRecord) {
|
||||
scopedTokenID := strings.TrimSpace(tokenID)
|
||||
filtered := make([]config.APITokenRecord, 0, len(tokens))
|
||||
var removed *config.APITokenRecord
|
||||
|
||||
for idx := range tokens {
|
||||
record := tokens[idx]
|
||||
if removed == nil && strings.TrimSpace(record.ID) == scopedTokenID {
|
||||
recordCopy := record
|
||||
removed = &recordCopy
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, record)
|
||||
}
|
||||
|
||||
return filtered, removed
|
||||
}
|
||||
|
||||
func createRelayMobileToken(args []string) {
|
||||
flags := flag.NewFlagSet("create", flag.ExitOnError)
|
||||
dataDir := flags.String("data-dir", "", "Path to the tenant root data directory that owns api_tokens.json")
|
||||
|
|
@ -152,19 +215,72 @@ func createRelayMobileToken(args []string) {
|
|||
DataDir: scopedDataDir,
|
||||
OrgID: scopedOrgID,
|
||||
PrunedCount: prunedCount,
|
||||
Record: helperTokenRecord{
|
||||
ID: record.ID,
|
||||
Metadata: record.Metadata,
|
||||
Name: record.Name,
|
||||
OrgID: record.OrgID,
|
||||
Scopes: append([]string{}, record.Scopes...),
|
||||
},
|
||||
Token: rawToken,
|
||||
Record: toHelperTokenRecord(*record),
|
||||
Token: rawToken,
|
||||
}); err != nil {
|
||||
fatalf("encode result: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteRelayMobileToken(args []string) {
|
||||
flags := flag.NewFlagSet("delete", flag.ExitOnError)
|
||||
dataDir := flags.String("data-dir", "", "Path to the tenant root data directory that owns api_tokens.json")
|
||||
tokenID := flags.String("token-id", "", "Token record id to delete")
|
||||
rawToken := flags.String("token", "", "Raw token value to resolve and delete")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
|
||||
scopedDataDir := strings.TrimSpace(*dataDir)
|
||||
if scopedDataDir == "" {
|
||||
usage("--data-dir is required")
|
||||
}
|
||||
|
||||
persistence := config.NewConfigPersistence(scopedDataDir)
|
||||
tokens, err := persistence.LoadAPITokens()
|
||||
if err != nil {
|
||||
fatalf("load api tokens: %v", err)
|
||||
}
|
||||
|
||||
record, err := findTokenRecord(tokens, *tokenID, *rawToken)
|
||||
if err != nil {
|
||||
fatalf("resolve token: %v", err)
|
||||
}
|
||||
if record == nil {
|
||||
fatalf("token not found")
|
||||
}
|
||||
|
||||
filteredTokens, removed := deleteTokenRecord(tokens, record.ID)
|
||||
if removed == nil {
|
||||
fatalf("token not found")
|
||||
}
|
||||
|
||||
cfg := &config.Config{APITokens: filteredTokens}
|
||||
cfg.SortAPITokens()
|
||||
if err := persistence.SaveAPITokens(cfg.APITokens); err != nil {
|
||||
fatalf("persist api tokens: %v", err)
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(deleteResult{
|
||||
Action: "delete",
|
||||
DataDir: scopedDataDir,
|
||||
Deleted: true,
|
||||
Record: ptrToHelperTokenRecord(removed),
|
||||
}); err != nil {
|
||||
fatalf("encode result: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrToHelperTokenRecord(record *config.APITokenRecord) *helperTokenRecord {
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
converted := toHelperTokenRecord(*record)
|
||||
return &converted
|
||||
}
|
||||
|
||||
func validateRelayMobileToken(args []string) {
|
||||
flags := flag.NewFlagSet("validate", flag.ExitOnError)
|
||||
dataDir := flags.String("data-dir", "", "Path to the tenant root data directory that owns api_tokens.json")
|
||||
|
|
@ -196,13 +312,7 @@ func validateRelayMobileToken(args []string) {
|
|||
Found: ok && record != nil,
|
||||
}
|
||||
if ok && record != nil {
|
||||
result.Record = &helperTokenRecord{
|
||||
ID: record.ID,
|
||||
Metadata: record.Metadata,
|
||||
Name: record.Name,
|
||||
OrgID: record.OrgID,
|
||||
Scopes: append([]string{}, record.Scopes...),
|
||||
}
|
||||
result.Record = ptrToHelperTokenRecord(record)
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
|
|
@ -222,6 +332,8 @@ func main() {
|
|||
switch strings.ToLower(strings.TrimSpace(os.Args[1])) {
|
||||
case "create":
|
||||
createRelayMobileToken(os.Args[2:])
|
||||
case "delete":
|
||||
deleteRelayMobileToken(os.Args[2:])
|
||||
case "validate":
|
||||
validateRelayMobileToken(os.Args[2:])
|
||||
case "--help", "-h", "help":
|
||||
|
|
|
|||
54
tests/integration/scripts/relay-mobile-token-helper_test.go
Normal file
54
tests/integration/scripts/relay-mobile-token-helper_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
)
|
||||
|
||||
func TestFindTokenRecordResolvesRawToken(t *testing.T) {
|
||||
rawToken, err := config.NewAPITokenRecord("relay-mobile-raw-token-123.12345678", "Pulse Mobile", []string{config.ScopeRelayMobileAccess})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPITokenRecord: %v", err)
|
||||
}
|
||||
otherToken, err := config.NewAPITokenRecord("other-raw-token-123.12345678", "Other", []string{config.ScopeRelayMobileAccess})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPITokenRecord other: %v", err)
|
||||
}
|
||||
|
||||
record, err := findTokenRecord([]config.APITokenRecord{*rawToken, *otherToken}, "", "relay-mobile-raw-token-123.12345678")
|
||||
if err != nil {
|
||||
t.Fatalf("findTokenRecord: %v", err)
|
||||
}
|
||||
if record == nil {
|
||||
t.Fatal("expected record, got nil")
|
||||
}
|
||||
if record.ID != rawToken.ID {
|
||||
t.Fatalf("record ID = %q, want %q", record.ID, rawToken.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTokenRecordRemovesOnlyMatchingToken(t *testing.T) {
|
||||
first, err := config.NewAPITokenRecord("relay-mobile-delete-1.12345678", "First", []string{config.ScopeRelayMobileAccess})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPITokenRecord first: %v", err)
|
||||
}
|
||||
second, err := config.NewAPITokenRecord("relay-mobile-delete-2.12345678", "Second", []string{config.ScopeRelayMobileAccess})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPITokenRecord second: %v", err)
|
||||
}
|
||||
|
||||
filtered, removed := deleteTokenRecord([]config.APITokenRecord{*first, *second}, second.ID)
|
||||
if removed == nil {
|
||||
t.Fatal("expected removed token, got nil")
|
||||
}
|
||||
if removed.ID != second.ID {
|
||||
t.Fatalf("removed ID = %q, want %q", removed.ID, second.ID)
|
||||
}
|
||||
if len(filtered) != 1 {
|
||||
t.Fatalf("filtered len = %d, want 1", len(filtered))
|
||||
}
|
||||
if filtered[0].ID != first.ID {
|
||||
t.Fatalf("remaining ID = %q, want %q", filtered[0].ID, first.ID)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue