The admin CLI moves a persona to another login and deletes a login; the deploy no longer signs in
The owner took @thepra, which the deploy had made and kept under its own login deploy-smoke for its signed-in check, for the administrator's login (owner decision 2026-10-17). No persona could change login, so: - admin move-persona <persona> <root> re-points the persona's link first, so the old login can no longer exchange a token for it, then ends its Mastodon tokens and authorizations. Its actor, posts and followers stay; nobody outside is told, since nothing they hold changes. - admin delete-root <root> deletes a login as the client deletes its own (RootRemoval): every persona it still has goes with it, announced deleted, and its sessions end. - admin smoke and the deploy's signed-in step are gone, since the step would make deploy-smoke again and fail to make @thepra under it. The deploy keeps its anonymous checks; tools/smoke/oauth.sh stays for the pasture's privapub_token. Tests: move-persona (the persona listed under its new login only, its old token refused, unknown persona or login exit 1) and delete-root (the login and its persona deleted, the login refused); the whole suite passes (963). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8156c1927c
commit
c42229f78c
6 files changed
+113
-94
No files matched your search
@@ -1,29 +1,26 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using OpenIddict.Abstractions;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.ClientModels.User.Avatar;
|
||||
using PrivaPub.Infrastructure.Backup;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Services;
|
||||
using PrivaPub.Services.ClientToServer.Private;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace PrivaPub.Infrastructure.Cli
|
||||
{
|
||||
// `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services.
|
||||
public static class AdminCommands
|
||||
{
|
||||
public const string SmokeLogin = "deploy-smoke";
|
||||
|
||||
const string Usage = """
|
||||
usage: PrivaPub admin promote|demote <root>
|
||||
PrivaPub admin create-root <login> [--admin] the password is read from standard input
|
||||
PrivaPub admin smoke <persona> prints "<login> <password>" for the deploy's signed-in check
|
||||
PrivaPub admin move-persona <persona> <root> gives a persona to another login; its Mastodon tokens end
|
||||
PrivaPub admin delete-root <root> deletes a login and every persona it still has
|
||||
PrivaPub admin media audit [--fix] media files against what holds them; --fix (as www-data) trashes
|
||||
what nothing holds and gives today's pictures their rows
|
||||
PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]
|
||||
@@ -51,8 +48,10 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
return await Promote(verb == "promote", userName, output);
|
||||
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
|
||||
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
|
||||
case ["smoke", var persona]:
|
||||
return await Smoke(services, persona, output);
|
||||
case ["move-persona", var persona, var root]:
|
||||
return await MovePersona(services, persona, root, output);
|
||||
case ["delete-root", var root]:
|
||||
return await DeleteRoot(services, root, output);
|
||||
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
|
||||
return await AuditMedia(services, flags.Contains("--fix"), output);
|
||||
case ["backup", "verify", var id]:
|
||||
@@ -230,58 +229,64 @@ namespace PrivaPub.Infrastructure.Cli
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
// The deploy's signed-in smoke check: a root nobody signs in to by hand, owning one undiscoverable persona. Every run
|
||||
// sets a new password and prints it, so no secret is kept anywhere and nothing waits on a person.
|
||||
static async Task<int> Smoke(IServiceProvider services, string personaName, TextWriter output)
|
||||
// A persona given to another login (owner decision 2026-10-17, made for @thepra): its link moves first, so the old
|
||||
// login can no longer exchange a token for it, then its Mastodon tokens and authorizations end. Its actor, posts and
|
||||
// followers stay as they are, and nobody outside is told: nothing they hold changes.
|
||||
static async Task<int> MovePersona(IServiceProvider services, string personaName, string login, TextWriter output)
|
||||
{
|
||||
personaName = personaName.ToLowerInvariant();
|
||||
var password = "Smoke-x" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(24));
|
||||
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
|
||||
if (root == default)
|
||||
{
|
||||
var created = await services.GetRequiredService<IRootUsersService>().SignUpAsync(new LoginForm { UserName = SmokeLogin, Password = password });
|
||||
if (!created.IsValid)
|
||||
{
|
||||
Console.Error.WriteLine(created.ErrorMessage);
|
||||
return 1;
|
||||
}
|
||||
root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
|
||||
}
|
||||
else if (root.DeletedAt.HasValue || root.IsBanned)
|
||||
{
|
||||
Console.Error.WriteLine($"the root '{SmokeLogin}' is deleted or banned");
|
||||
return 1;
|
||||
}
|
||||
else
|
||||
await DB.Default.Update<RootUser>().MatchID(root.ID)
|
||||
.Modify(u => u.HashedPassword, services.GetRequiredService<IPasswordHasher>().Hash(password))
|
||||
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
|
||||
.ExecuteAsync();
|
||||
|
||||
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
|
||||
var persona = await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
|
||||
personaName = personaName.TrimStart('@').ToLowerInvariant();
|
||||
login = login.ToLowerInvariant();
|
||||
var persona = await DB.Default.Find<Avatar>().Match(a => a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
|
||||
if (persona == default)
|
||||
{
|
||||
var inserted = await services.GetRequiredService<IPrivateAvatarUsersService>().InsertAvatar(new InsertAvatarForm
|
||||
{
|
||||
RootId = root.ID,
|
||||
UserName = personaName,
|
||||
Name = personaName,
|
||||
Biography = "The deploy signs in here to check that the Mastodon API works for a signed-in persona."
|
||||
});
|
||||
if (!inserted.IsValid)
|
||||
{
|
||||
Console.Error.WriteLine(inserted.ErrorMessage);
|
||||
return 1;
|
||||
}
|
||||
persona = await DB.Default.Find<Avatar>().MatchID(((ViewAvatar)inserted.Data).Id).ExecuteFirstAsync();
|
||||
Console.Error.WriteLine($"no persona '@{personaName}'");
|
||||
return 1;
|
||||
}
|
||||
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login).ExecuteFirstAsync();
|
||||
if (root == default || root.DeletedAt.HasValue || root.IsBanned)
|
||||
{
|
||||
Console.Error.WriteLine($"no root user '{login}' that can take it");
|
||||
return 1;
|
||||
}
|
||||
var link = await DB.Default.Find<RootToAvatar>().Match(ra => ra.AvatarId == persona.ID).ExecuteFirstAsync();
|
||||
if (link == default)
|
||||
{
|
||||
Console.Error.WriteLine($"'@{personaName}' belongs to no login");
|
||||
return 1;
|
||||
}
|
||||
if (link.RootId == root.ID)
|
||||
{
|
||||
output.WriteLine($"@{personaName}: already {login}'s");
|
||||
return 0;
|
||||
}
|
||||
await DB.Default.Update<Avatar>().MatchID(persona.ID)
|
||||
.Modify(a => a.Settings.IsDiscoverable, false)
|
||||
.Modify(a => a.Settings.IsIndexable, false)
|
||||
.ExecuteAsync();
|
||||
|
||||
output.WriteLine($"{SmokeLogin} {password}");
|
||||
await DB.Default.Update<RootToAvatar>().MatchID(link.ID).Modify(ra => ra.RootId, root.ID).ExecuteAsync();
|
||||
await services.GetRequiredService<IOpenIddictTokenManager>().RevokeBySubjectAsync(persona.ID);
|
||||
await services.GetRequiredService<IOpenIddictAuthorizationManager>().RevokeBySubjectAsync(persona.ID);
|
||||
output.WriteLine($"@{personaName}: moved to {login}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A login deleted from the box as the client deletes its own (RootRemoval): every persona it still has goes with it,
|
||||
// announced deleted, and its sessions end.
|
||||
static async Task<int> DeleteRoot(IServiceProvider services, string login, TextWriter output)
|
||||
{
|
||||
login = login.ToLowerInvariant();
|
||||
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null).ExecuteFirstAsync();
|
||||
if (root == default)
|
||||
{
|
||||
Console.Error.WriteLine($"no root user '{login}'");
|
||||
return 1;
|
||||
}
|
||||
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
|
||||
var personas = (await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync())
|
||||
.Select(a => "@" + a.UserName).ToList();
|
||||
if (!await services.GetRequiredService<IRootRemoval>().Remove(root.ID, default))
|
||||
{
|
||||
Console.Error.WriteLine($"'{login}' could not be deleted");
|
||||
return 1;
|
||||
}
|
||||
output.WriteLine($"{login}: deleted{(personas.Count > 0 ? ", with " + string.Join(", ", personas) : string.Empty)}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user