Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions graphql/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1676,6 +1676,31 @@ test("addAccountMigrationAlias returns typed errors", async () => {
"NotAuthorizedError",
);

// A well-formed but nonexistent account id is also rejected with
// NotAuthorizedError, so the error does not leak whether the id exists.
const missing = await execute({
schema,
document: addAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID(
"Account",
"00000000-0000-4000-8000-000000000000",
),
actor: "@old@remote.example",
},
},
contextValue: makeUserContext(tx, owner.account),
onError: "NO_PROPAGATE",
});
assert.equal(missing.errors, undefined);
assert.equal(
(toPlainJson(missing.data) as {
addAccountMigrationAlias?: { __typename?: string };
}).addAccountMigrationAlias?.__typename,
"NotAuthorizedError",
);

const invalid = await execute({
schema,
document: addAccountMigrationAliasMutation,
Expand Down Expand Up @@ -1778,6 +1803,159 @@ test("removeAccountMigrationAlias removes one alias idempotently", async () => {
});
});

test("account migration aliases are manageable by organization admins", async () => {
await withRollback(async (tx) => {
const admin = await insertAccountWithActor(tx, {
username: "orgmigadmin",
name: "Org Migration Admin",
email: "orgmigadmin@example.com",
});
const member = await insertAccountWithActor(tx, {
username: "orgmigmember",
name: "Org Migration Member",
email: "orgmigmember@example.com",
});
const outsider = await insertAccountWithActor(tx, {
username: "orgmigoutsider",
name: "Org Migration Outsider",
email: "orgmigoutsider@example.com",
});
const organization = await insertAccountWithActor(tx, {
username: "orgmigration",
name: "Org Migration",
email: "orgmigration@example.com",
kind: "organization",
type: "Organization",
});
await tx.insert(organizationMembershipTable).values([
{
organizationAccountId: organization.account.id,
memberAccountId: admin.account.id,
role: "admin",
invitedById: admin.account.id,
accepted: new Date("2026-04-15T00:00:00.000Z"),
},
{
organizationAccountId: organization.account.id,
memberAccountId: member.account.id,
role: "member",
invitedById: admin.account.id,
accepted: new Date("2026-04-15T00:00:00.000Z"),
},
]);
await insertRemoteActor(tx, {
username: "orgoldalias",
name: "Org Old Alias",
host: "old.example",
iri: "https://old.example/users/orgoldalias",
url: "https://old.example/@orgoldalias",
});

const fedCtx = createFedCtx(tx);
fedCtx.getActor = (identifier: string) =>
Promise.resolve(
new vocab.Organization({ id: fedCtx.getActorUri(identifier) }),
);
const sentActivities: unknown[][] = [];
fedCtx.sendActivity = ((...args: unknown[]) => {
sentActivities.push(args);
return Promise.resolve(undefined);
}) as typeof fedCtx.sendActivity;

// An accepted admin can add an alias to the organization account.
const added = await execute({
schema,
document: addAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID("Account", organization.account.id),
actor: "@orgoldalias@old.example",
},
},
contextValue: makeUserContext(tx, admin.account, { fedCtx }),
onError: "NO_PROPAGATE",
});
assert.equal(added.errors, undefined);
assert.deepEqual(toPlainJson(added.data), {
addAccountMigrationAlias: {
__typename: "AddAccountMigrationAliasPayload",
account: {
actor: {
aliases: ["https://old.example/users/orgoldalias"],
},
},
},
});

// ... and remove it again.
const removed = await execute({
schema,
document: removeAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID("Account", organization.account.id),
alias: "https://old.example/users/orgoldalias",
},
},
contextValue: makeUserContext(tx, admin.account, { fedCtx }),
onError: "NO_PROPAGATE",
});
assert.equal(removed.errors, undefined);
assert.deepEqual(toPlainJson(removed.data), {
removeAccountMigrationAlias: {
__typename: "RemoveAccountMigrationAliasPayload",
account: {
actor: {
aliases: [],
},
},
},
});

// A non-admin member cannot manage the organization's migration aliases.
const byMember = await execute({
schema,
document: addAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID("Account", organization.account.id),
actor: "@orgoldalias@old.example",
},
},
contextValue: makeUserContext(tx, member.account, { fedCtx }),
onError: "NO_PROPAGATE",
});
assert.equal(byMember.errors, undefined);
assert.equal(
(toPlainJson(byMember.data) as {
addAccountMigrationAlias?: { __typename?: string };
}).addAccountMigrationAlias?.__typename,
"NotAuthorizedError",
);

// Neither can an account that is not a member at all.
const byOutsider = await execute({
schema,
document: addAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID("Account", organization.account.id),
actor: "@orgoldalias@old.example",
},
},
contextValue: makeUserContext(tx, outsider.account, { fedCtx }),
onError: "NO_PROPAGATE",
});
assert.equal(byOutsider.errors, undefined);
assert.equal(
(toPlainJson(byOutsider.data) as {
addAccountMigrationAlias?: { __typename?: string };
}).addAccountMigrationAlias?.__typename,
"NotAuthorizedError",
);
});
});

test("deleteAccount deletes the viewer account and session", async () => {
await withRollback(async (tx) => {
const { kv } = createTestKv();
Expand Down
30 changes: 23 additions & 7 deletions graphql/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,25 @@ async function getAuthorizedMigrationAccount(
throw new NotAuthenticatedError();
}
if (!validateUuid(accountId)) throw new InvalidInputError("accountId");
if (session.accountId !== accountId) throw new NotAuthorizedError();
const account = await ctx.db.query.accountTable.findFirst({
where: { id: accountId },
with: { actor: true },
});
if (account?.actor == null) throw new InvalidInputError("accountId");
// Treat a nonexistent account the same as an unauthorized one so the error
// does not leak whether the id exists (the input description promises that
// any account id the viewer cannot manage returns `NotAuthorizedError`).
if (
account == null ||
!(await viewerCanManageAccountSettings(
ctx,
account.id,
account.kind,
session.accountId,
))
) {
throw new NotAuthorizedError();
}
if (account.actor == null) throw new InvalidInputError("accountId");
return account;
}

Expand Down Expand Up @@ -1454,8 +1467,10 @@ builder.relayMutationField(
for: Account,
required: true,
description:
"The `Account` global id owned by the authenticated viewer. " +
"Passing another account id returns `NotAuthorizedError`.",
"The `Account` global id to add the alias to. The authenticated " +
"viewer must be able to manage that account: its personal holder, " +
"or an accepted `admin` of an `ORGANIZATION` account. Any other " +
"account id returns `NotAuthorizedError`.",
Comment thread
dahlia marked this conversation as resolved.
}),
actor: t.string({
required: true,
Expand Down Expand Up @@ -1514,9 +1529,10 @@ builder.relayMutationField(
accountId: t.globalID({
for: Account,
required: true,
description:
"The `Account` global id owned by the authenticated viewer. " +
"Passing another account id returns `NotAuthorizedError`.",
description: "The `Account` global id to remove the alias from. The " +
"authenticated viewer must be able to manage that account: its " +
"personal holder, or an accepted `admin` of an `ORGANIZATION` " +
"account. Any other account id returns `NotAuthorizedError`.",
}),
alias: t.field({
type: "URL",
Expand Down
4 changes: 2 additions & 2 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,7 @@ Add a previous account actor as an ActivityPub migration alias for the authentic
"""
input AddAccountMigrationAliasInput {
"""
The `Account` global id owned by the authenticated viewer. Passing another account id returns `NotAuthorizedError`.
The `Account` global id to add the alias to. The authenticated viewer must be able to manage that account: its personal holder, or an accepted `admin` of an `ORGANIZATION` account. Any other account id returns `NotAuthorizedError`.
"""
accountId: ID!

Expand Down Expand Up @@ -5291,7 +5291,7 @@ Remove one ActivityPub migration alias from the authenticated viewer's local acc
"""
input RemoveAccountMigrationAliasInput {
"""
The `Account` global id owned by the authenticated viewer. Passing another account id returns `NotAuthorizedError`.
The `Account` global id to remove the alias from. The authenticated viewer must be able to manage that account: its personal holder, or an accepted `admin` of an `ORGANIZATION` account. Any other account id returns `NotAuthorizedError`.
"""
accountId: ID!

Expand Down
16 changes: 16 additions & 0 deletions web-next/src/routes/(root)/[handle]/settings/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,22 @@ export default function AccountSettingsPage() {
account={account}
viewerId={data.viewer?.id ?? null}
/>
<Card>
<CardHeader>
<CardTitle>
{t`Account migration`}
</CardTitle>
<CardDescription>
{t`Prepare this account as the destination for a Mastodon-style move.`}
</CardDescription>
</CardHeader>
<CardContent>
<AccountMigrationAliasesForm
id={account.id}
aliases={account.actor.aliases}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
/>
</CardContent>
</Card>
Comment thread
dahlia marked this conversation as resolved.
Outdated
<Card>
<CardHeader>
<CardTitle>{t`Delete organization`}</CardTitle>
Expand Down
Loading