Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d6365aa
feat: emit user_creation and tenant_disassociation lifecycle events
Aug 30, 2026
c03bba4
feat: serve approximate user counts from the lifecycle-event fold
Aug 30, 2026
a971ae9
feat: serve default user counts as anchor plus ledger fold
Aug 30, 2026
d4dff4c
docs: condense approximate-count changelog entry to a one-liner
Aug 30, 2026
cda62be
test: pin param-less approximate/asOf fields for exact-fallback count…
Aug 30, 2026
63892a9
test: lock serve()-level unlink (+1) and corrupt-payload re-anchor
Aug 30, 2026
9da1fb3
test: pin webauthn and fake-email user_creation emits; clarify tenant…
Aug 30, 2026
7097d1a
feat: emit user_import lifecycle events from bulk import
Aug 31, 2026
8b7e99d
Merge remote-tracking branch 'origin/feat/activity-log' into agent/is…
Aug 31, 2026
a70823c
Merge remote-tracking branch 'origin/agent/issue-1378-ledger-fold' in…
Aug 31, 2026
145a376
feat: replace user_last_active event with semantic activity events
Aug 31, 2026
9957bcb
docs: condense semantic-activity-events changelog entry to a one-liner
Aug 31, 2026
2e1886b
feat: write semantic activity events on a fail-loud audited transaction
Aug 31, 2026
35f8610
fix: keep semantic activity events best-effort, make the throttle con…
Aug 31, 2026
086fcb3
Merge pull request #1405 from supertokens/agent/issue-1379-default-flip
tamassoltesz Aug 31, 2026
09ff249
Merge branch 'agent/issue-1397-emit-creation-disassoc' into agent/iss…
tamassoltesz Aug 31, 2026
cf0c425
Merge pull request #1404 from supertokens/agent/issue-1378-ledger-fold
tamassoltesz Aug 31, 2026
da767fe
fix: wake the last-active rollup on user_creation and account_linking
Aug 31, 2026
8ce64d1
test: cover cross-login-method tenant union in bulk-import user_import
Aug 31, 2026
65aaf78
refactor: consume shared plugin-interface activity-event vocabulary
Sep 1, 2026
37b2520
test: pin the account_linking rollup nudge with a link-only promptnes…
Sep 1, 2026
0712835
Merge pull request #1408 from supertokens/agent/issue-1407-semantic-a…
tamassoltesz Sep 1, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `ActiveUsers.updateLastActive` no longer writes `user_last_active` directly; it only appends the throttled `user_last_active` activity-log event, and the `RollupUserLastActive` cron is now the sole writer of the projection (so counts reflect activity within a rollup interval).
- Added Phase-1 parity tests proving the last-active rollup derives the same `countUsersActiveSince` answer (and per-user projection) as the direct write, including link/unlink cases (`ActivityLogRollupParityTest`).
- Adds an observability-only shadow audit to the approximate-user-count background refresh; discrepancies are logged and emitted as telemetry, never served.
- Emits `user_creation` lifecycle events atomically with the mutation from the interactive sign-up paths (email-password sign-up and password-hash import, third-party sign-in-up, passwordless user creation, WebAuthn sign-up), and `tenant_disassociation` events from `Multitenancy.removeUserIdFromTenant`, through `startAuditedTransaction`; the `@UnauditedTransaction` allowlist baseline drops from 57 to 55. Bulk-import `user_creation` emission is not yet included.
Comment thread
tamassoltesz marked this conversation as resolved.
Outdated

## [12.2.0]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,29 @@ public static AuditLogEvent forTenantAssociation(AppIdentifier appIdentifier, St
LifecycleEventPayload.forTenantAssociation(groupBefore, tenantId), createdAt);
}

/**
* A {@code tenant_disassociation} event: the group identified by {@code groupUserId} (via member
* {@code recipeUserId}) was removed from tenant {@code tenantId}. The payload carries the group's presence
* list before the disassociation plus the tenant it was removed from.
*/
public static AuditLogEvent forTenantDisassociation(AppIdentifier appIdentifier, String recipeUserId,
String groupUserId, GroupPresence groupBefore, String tenantId, long createdAt) {
return build(appIdentifier, recipeUserId, groupUserId,
LifecycleEventPayload.forTenantDisassociation(groupBefore, tenantId), createdAt);
}

/**
* A {@code user_creation} event: a new recipe user {@code recipeUserId} was created in tenant
* {@code tenantId}. A freshly created user is its own group, so it is recorded against itself as both the
* recipe user and the group; the payload carries only the tenant it was created in (the group's presence
* afterwards is exactly that single tenant, derivable read-side without a stored list).
*/
public static AuditLogEvent forUserCreation(AppIdentifier appIdentifier, String recipeUserId,
String tenantId, long createdAt) {
return build(appIdentifier, recipeUserId, recipeUserId,
LifecycleEventPayload.forUserCreation(tenantId), createdAt);
}

private static AuditLogEvent build(AppIdentifier appIdentifier, String recipeUserId,
String primaryOrRecipeUserId, LifecycleEventPayload payload, long createdAt) {
return new AuditLogEvent(
Expand Down
101 changes: 69 additions & 32 deletions src/main/java/io/supertokens/emailpassword/EmailPassword.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@
import io.supertokens.Main;
import io.supertokens.ResourceDistributor;
import io.supertokens.auditlog.AuditLog;
import io.supertokens.pluginInterface.auditlog.ActivityLogSQLStorage;
import io.supertokens.pluginInterface.auditlog.AuditLogEvent;
import io.supertokens.pluginInterface.auditlog.AuditedResult;
import io.supertokens.auditlog.lifecycle.LifecycleAuditEvent;
import io.supertokens.authRecipe.AuthRecipe;
import io.supertokens.config.Config;
import io.supertokens.config.CoreConfig;
Expand Down Expand Up @@ -106,7 +109,6 @@ public static AuthRecipeUserInfo signUp(Main main, @Nonnull String email, @Nonnu
}
}

@UnauditedTransaction(justification = "Legacy unaudited transaction (PLAN-012 backlog); pending conversion to startAuditedTransaction or read-only exemption.")
public static AuthRecipeUserInfo signUp(TenantIdentifier tenantIdentifier, Storage storage, Main main,
Comment thread
tamassoltesz marked this conversation as resolved.
@Nonnull String email, @Nonnull String password)
throws DuplicateEmailException, StorageQueryException, TenantOrAppNotFoundException,
Expand All @@ -120,45 +122,55 @@ public static AuthRecipeUserInfo signUp(TenantIdentifier tenantIdentifier, Stora
String hashedPassword = PasswordHashing.getInstance(main)
.createHashWithSalt(tenantIdentifier.toAppIdentifier(), password);

EmailPasswordSQLStorage epStorage = StorageUtils.getEmailPasswordStorage(storage);
EmailVerificationSQLStorage evStorage = StorageUtils.getEmailVerificationStorage(storage);
ActivityLogSQLStorage auditStorage = (ActivityLogSQLStorage) storage;
AppIdentifier appIdentifier = tenantIdentifier.toAppIdentifier();
boolean isFakeEmail = Utils.isFakeEmail(email);

while (true) {
String userId = Utils.getUUID();
long timeJoined = System.currentTimeMillis();

try {
AuthRecipeUserInfo newUser = StorageUtils.getEmailPasswordStorage(storage)
.signUp(tenantIdentifier, userId, email, hashedPassword, timeJoined);

if (Utils.isFakeEmail(email)) {
// Create the user (and, for a fake email, mark it verified) and emit its user_creation event
// atomically on the same connection, so the event cannot be lost relative to the creation it
// records. The user-id retry loop on DuplicateUserIdException stays outside the transaction.
AuthRecipeUserInfo newUser = auditStorage.startAuditedTransaction(appIdentifier, con -> {
try {
EmailVerificationSQLStorage evStorage = StorageUtils.getEmailVerificationStorage(storage);
evStorage.startTransaction(con -> {
try {
evStorage.updateIsEmailVerified_Transaction(tenantIdentifier.toAppIdentifier(), con,
newUser.getSupertokensUserId(), email, true);
evStorage.commitTransaction(con);

return null;
} catch (TenantOrAppNotFoundException e) {
throw new StorageTransactionLogicException(e);
}
});
newUser.loginMethods[0].setVerified(); // newly created user has only one loginMethod
} catch (StorageTransactionLogicException e) {
if (e.actualException instanceof TenantOrAppNotFoundException) {
throw (TenantOrAppNotFoundException) e.actualException;
AuthRecipeUserInfo user = epStorage.signUp_Transaction(tenantIdentifier, con, userId, email,
hashedPassword, timeJoined);
if (isFakeEmail) {
// A fake email is treated as pre-verified; do it on the same connection so the user,
// its verification and the user_creation event all commit (or roll back) together.
evStorage.updateIsEmailVerified_Transaction(appIdentifier, con,
user.getSupertokensUserId(), email, true);
user.loginMethods[0].setVerified(); // newly created user has only one loginMethod
}
throw new StorageQueryException(e);
AuditLogEvent event = LifecycleAuditEvent.forUserCreation(appIdentifier,
user.getSupertokensUserId(), tenantIdentifier.getTenantId(), timeJoined);
return new AuditedResult<>(user, event);
} catch (DuplicateUserIdException | DuplicateEmailException e) {
throw new StorageTransactionLogicException(e);
}
}
});

AuditLog.emit(main, storage, tenantIdentifier, new AuditLogEvent(
tenantIdentifier.getAppId(), tenantIdentifier.getTenantId(),
newUser.getSupertokensUserId(), newUser.getSupertokensUserId(),
"emailpassword_sign_up", "success", "email", email,
System.currentTimeMillis(), null));
return newUser;
} catch (DuplicateUserIdException ignored) {
// we retry with a new userId (while loop)
} catch (StorageTransactionLogicException e) {
if (e.actualException instanceof DuplicateUserIdException) {
// we retry with a new userId (while loop)
continue;
} else if (e.actualException instanceof DuplicateEmailException) {
throw (DuplicateEmailException) e.actualException;
} else if (e.actualException instanceof TenantOrAppNotFoundException) {
throw (TenantOrAppNotFoundException) e.actualException;
}
throw new StorageQueryException(e.actualException);
}
}
}
Expand Down Expand Up @@ -229,24 +241,47 @@ public static ImportUserResponse importUserWithPasswordHash(TenantIdentifier ten
return response;
}

// The transaction opened directly below (the duplicate-email password update) is a non-count-affecting
// mutation and remains on the @UnauditedTransaction allowlist (PLAN-012 backlog). The user *creation* path,
// by contrast, now goes through startAuditedTransaction and emits its user_creation event atomically.
@UnauditedTransaction(justification = "Legacy unaudited transaction (PLAN-012 backlog); pending conversion to startAuditedTransaction or read-only exemption.")
public static ImportUserResponse createUserWithPasswordHash(TenantIdentifier tenantIdentifier, Storage storage,
@Nonnull String email,
@Nonnull String passwordHash, long timeJoined)
throws StorageQueryException, DuplicateEmailException, TenantOrAppNotFoundException,
StorageTransactionLogicException {
EmailPasswordSQLStorage epStorage = StorageUtils.getEmailPasswordStorage(storage);
ActivityLogSQLStorage auditStorage = (ActivityLogSQLStorage) storage;
AppIdentifier appIdentifier = tenantIdentifier.toAppIdentifier();
while (true) {
String userId = Utils.getUUID();
try {
AuthRecipeUserInfo userInfo = null;
userInfo = epStorage.signUp(tenantIdentifier, userId, email, passwordHash, timeJoined);
// Create the user and emit its user_creation event atomically on the same connection; the user-id
// retry loop on DuplicateUserIdException stays outside the transaction.
AuthRecipeUserInfo userInfo = auditStorage.startAuditedTransaction(appIdentifier, con -> {
try {
AuthRecipeUserInfo user = epStorage.signUp_Transaction(tenantIdentifier, con, userId, email,
passwordHash, timeJoined);
AuditLogEvent event = LifecycleAuditEvent.forUserCreation(appIdentifier,
user.getSupertokensUserId(), tenantIdentifier.getTenantId(), timeJoined);
return new AuditedResult<>(user, event);
} catch (DuplicateUserIdException | DuplicateEmailException e) {
throw new StorageTransactionLogicException(e);
}
});
return new ImportUserResponse(false, userInfo);
} catch (DuplicateUserIdException e) {
// we retry with a new userId
} catch (DuplicateEmailException e) {
if(epStorage instanceof BulkImportStorage){
throw e;
} catch (StorageTransactionLogicException e) {
if (e.actualException instanceof DuplicateUserIdException) {
// we retry with a new userId
continue;
} else if (e.actualException instanceof TenantOrAppNotFoundException) {
throw (TenantOrAppNotFoundException) e.actualException;
} else if (!(e.actualException instanceof DuplicateEmailException)) {
throw new StorageQueryException(e.actualException);
}
// DuplicateEmailException: an email-password user with this email already exists in the tenant.
if (epStorage instanceof BulkImportStorage) {
throw (DuplicateEmailException) e.actualException;
}
AuthRecipeUserInfo[] allUsers = epStorage.listPrimaryUsersByEmail(tenantIdentifier, email);
AuthRecipeUserInfo userInfoToBeUpdated = null;
Expand All @@ -271,6 +306,8 @@ public static ImportUserResponse createUserWithPasswordHash(TenantIdentifier ten
});
return new ImportUserResponse(true, userInfoToBeUpdated);
}
// No matching user to update (e.g. the duplicate is on another tenant): retry the create loop,
// matching the original behaviour.
}
}
}
Expand Down
50 changes: 48 additions & 2 deletions src/main/java/io/supertokens/multitenancy/Multitenancy.java
Original file line number Diff line number Diff line change
Expand Up @@ -582,12 +582,58 @@ public static boolean removeUserIdFromTenant(Main main, TenantIdentifier tenantI
}

boolean finalDidExist = false;
// The non-auth-recipe cleanup is not count-affecting (it removes roles/metadata-style mappings, not an
// auth user's presence in the tenant), so it stays a separate step outside the audited transaction.
boolean didExist = AuthRecipe.deleteNonAuthRecipeUser(tenantIdentifier, storage,
externalUserId == null ? userId : externalUserId);
finalDidExist = finalDidExist || didExist;

didExist = StorageUtils.getMultitenancyStorage(storage)
.removeUserIdFromTenant(tenantIdentifier, userId);
AuthRecipeSQLStorage authRecipeStorage = StorageUtils.getAuthRecipeStorage(storage);
UserLockingStorage userLockingStorage = (UserLockingStorage) storage;
ActivityLogSQLStorage auditStorage = (ActivityLogSQLStorage) storage;
MultitenancySQLStorage mtStorage = (MultitenancySQLStorage) storage;
AppIdentifier appIdentifier = tenantIdentifier.toAppIdentifier();
long now = System.currentTimeMillis();
try {
// startAuditedTransaction owns the commit and writes the tenant_disassociation event on the same
// connection as the mapping change, so the event cannot be lost relative to the disassociation it
// records.
didExist = auditStorage.startAuditedTransaction(appIdentifier, con -> {
try {
// Lock the user first so the before-presence snapshot is consistent with the removal (the same
// ordering as addUserIdToTenant). A user that does not exist locks nothing and removes nothing:
// a no-op with no count change, so no event is emitted.
userLockingStorage.lockUser(appIdentifier, con, userId);
} catch (UserNotFoundForLockingException e) {
return AuditedResult.withoutAudit(false,
Comment thread
tamassoltesz marked this conversation as resolved.
"User does not exist, so no mapping was removed: a no-op with no count change, so no "
+ "tenant_disassociation event is emitted.");
}

// Capture the group's tenant-presence before the disassociation (after the lock, so it is a
// consistent snapshot): any member id resolves to its group via getPrimaryUserById_Transaction.
AuthRecipeUserInfo groupInfo = authRecipeStorage.getPrimaryUserById_Transaction(appIdentifier,
con, userId);
GroupPresence groupBefore = groupInfo != null
? new GroupPresence(groupInfo.getSupertokensUserId(),
new ArrayList<>(groupInfo.tenantIds))
: new GroupPresence(userId, new ArrayList<>());

boolean removed = mtStorage.removeUserIdFromTenant_Transaction(tenantIdentifier, con, userId);
if (!removed) {
// The user was not associated with the tenant: no count-affecting mutation, so no event.
return AuditedResult.withoutAudit(false,
"User was not associated with the tenant (removeUserIdFromTenant_Transaction returned "
+ "false): a no-op with no count change, so no tenant_disassociation event is "
+ "emitted.");
}
AuditLogEvent event = LifecycleAuditEvent.forTenantDisassociation(appIdentifier, userId,
groupBefore.primaryOrRecipeUserId, groupBefore, tenantIdentifier.getTenantId(), now);
return new AuditedResult<>(true, event);
});
} catch (StorageTransactionLogicException e) {
throw new StorageQueryException(e.actualException);
}
finalDidExist = finalDidExist || didExist;

return finalDidExist;
Expand Down
Loading
Loading