Adding an account setting
This guide adds a setting that an account’s owner changes, and that support may
change on their behalf, such as the display name or the profile picture. One
AccountService operation serves every surface: the owner’s settings page and
account API, the admin dashboard and the auth-service account command.
What you’ll have at the end
- An
AccountOperationvariant, which the owner’s account API must serve and the CLI must run: a match and a test fail until they do. - An
AccountServicemethod that runs the same rules whoever acts, and records the change, with an administrator’s reason, in the account’s history. - The setting on the owner’s settings page, on the account’s dashboard page and
in the
accountcommand. - The setting in the account’s data export, and the owner told when support changes it.
Before you start
Decide who may do what, by the rule in changing an account on its owner’s behalf: administrators may view, edit and remove, but never add a way to sign in or to receive the account’s email.
- What both may do joins
AccountOperation, and its method takes aViewerto read or aSubjectto change. - What only the owner may do, such as adding a passkey, takes an
Owner, which only the session cookie builds. It is noAccountOperation, so the CLI and the dashboard have nothing to add: give it a route of its feature’s application, as setting the profile picture has inPictureApplication.
New tables or columns come first: adding a migration.
1. Name the operation
Add a variant, with its doc comment, to AccountOperation in
apps/auth-service/src/account/mod.rs:
#![allow(unused)]
fn main() {
/// Remove the account's profile picture.
RemovePicture,
}
The next steps fill in what this breaks:
- the build, until the account API serves it in
AccountApplication::route; - the tests’ build, until
AccountOperation::invocationandAccountOperation::readname it; - the test
every_operation_has_its_command, until anaccountcommand runs it.
2. Write the service method
Write the method in an impl AccountService block of the setting’s module, such
as apps/auth-service/src/pictures/mod.rs:
- A read takes
&Viewerand callsviewer.authorize(&self.database)first. For an administrator, that checks their rights and records a view of the account. - A change takes
&Subject. It locks the account withsubject.lock(&mut tx), orsubject.lock_confirmed(&mut tx)when the owner must have confirmed with a passkey recently, records its event withsubject.context(), and ends withsubject.commit(tx), which checks an administrator once more:
#![allow(unused)]
fn main() {
pub(crate) async fn remove_picture(&self, subject: &Subject) -> Result<(), AccountError> {
let mut tx = self.database.begin().await?;
subject.lock(&mut tx).await?;
if !tx.pictures().remove(subject.user()).await? {
return Err(PictureError::Missing.into());
}
tx.record(
&subject.context(),
Some(subject.user()),
DomainEvent::PictureRemoved {},
)
.await?;
subject.commit(tx).await
}
}
A Subject of an administrator carries their reason, and subject.context()
attaches it to the event. A change that signs the owner out elsewhere ends
subject.others(), every session but the owner’s own, or every one when an
administrator acts, for subject.revocation().
Its refusals are the module’s error, such as PictureError, with its own From
into ApiError, which sets each refusal’s status and message. A new module’s
error joins AccountError (apps/auth-service/src/account/error.rs) as a
#[from] variant, with its arm in From<AccountError> for ApiError.
3. Serve it on the account API
Give it a path under /account/ in apps/auth-service/src/routes.rs, then its
arm in AccountApplication::route, and its handler in impl AccountApplication:
#![allow(unused)]
fn main() {
Op::RemovePicture => (pictures::OWN, delete(Self::remove_picture)),
}
The handler extracts Viewer, Subject, or ConfirmedSubject for a change that
needs a recent passkey, which then answers step_up_required before reading the
path or body. Methods on one path merge, here and with the owner-only methods of
the same path.
4. Add it to the settings page
The owner’s settings page calls the same method: its AccountPage::apply passes
Subject::from(owner), and its render reads through reader.viewer(). Add the
change to the page’s change type and its component in sourceorigin-ui, following
adding a page. The component takes an Audience, so it renders the
owner’s form or the administrator’s, which asks for a reason.
5. Add the CLI command
In apps/auth-service/src/cli/account.rs, add a command to the setting’s
subcommand enum, such as Picture, or a new Setting variant for a new setting:
#![allow(unused)]
fn main() {
/// Remove the picture, such as one that impersonates someone.
Remove {
/// Why.
#[command(flatten)]
change: Change,
},
}
Then:
- Name its operation in
AccountCommand::operation:Picture::Remove { .. } => Op::RemovePicture. - Run it in
AccountCommand::run: a change aschange.on(id, console), the operator’sSubjectwith the--reasontheChangeflattens, and a read asread(), printed withoutput.print. - If it cuts off access, as removing a passkey does, flatten a
Confirminto it.
6. Add it to the dashboard
The account’s dashboard pages are in apps/auth-service/src/web/admin/settings.rs,
one per settings tab. Each renders the owner’s own sourceorigin-ui section with
Audience::Administrator, and posts the owner’s change with a reason:
#![allow(unused)]
fn main() {
let (admin, user) = App::settings(cx).await?;
let form = App::posted::<Administered<ConnectedAppChange>>(cx, body).await?;
let refused = App::submit(cx, form, App::here(cx), |posted| async move {
let subject = App::subject(user, admin, posted.reason)?;
// Call the service with `subject`, mapping its error with `Refusal::from`.
})
.await?;
}
Reads take App::viewer(user, admin), and the page ends with
App::settings_page. A new tab is mounted with .route(settings::…) in
AdminApplication::routes (web/admin/mod.rs), and linked from
App::settings_tab and from the account’s page in web/admin/users.rs.
7. Add it to the data export
The owner downloads everything auth stores about their account, as
AccountExport (apps/auth-service/src/deletion/export/mod.rs). If the setting
stores something new:
- Add a field to
AccountExport, with a doc comment saying what it holds. - Read it in
AccountExport::read, from the export’s transaction, so the export is one snapshot. - Raise
VERSION, and say what the version added in the struct’s doc comment.
The export never holds a secret, a digest or key material.
8. Record the event and tell the owner
The change records a DomainEvent, whose arms decide whether the owner sees it
in their security history and whether they are told. See
recording its event: the steps are the
same. A change the owner makes to their own account usually tells them nothing
they don’t know; one support makes does. DomainEvent::notification tells them
apart by its actor: see adding a notification.
Test it
- Test the method in the setting’s module, such as
apps/auth-service/src/pictures/tests.rs, as the owner and as an operator. - Say whether the operation reads, in
AccountOperation::read(apps/auth-service/src/account/tests.rs).every_settings_read_checks_its_administratorruns every read as an administrator who may no longer read. - Give it a command line in
AccountOperation::invocation(apps/auth-service/src/cli/tests.rs), whichevery_operation_has_its_commandparses. - Test the settings page and the dashboard page, in
apps/auth-service/src/web/settings/tests.rsandweb/admin/tests.rs. - If the export grew, add the new field to
serializes_every_category_in_its_documented_shape(deletion/export/tests.rs), which builds an export and checks each category’s name and shape.
just test -p auth-service
just db test -E 'test(account) | test(settings)'
Document it
- Run
just docs referencefor the new command’s reference. - Add the setting to the table in changing an account on its owner’s behalf, as shared or owner only.
- Describe it in the user guide, through the settings page, and for support, in administration.
- If it notifies the owner, add it to the list in notifications. If the export grew, say so in data export and account deletion.
Every surface gets the setting, as every surface gets the feature asks.