fix: policy serialization (#13211)

* fix: Membership IDs not serialized correctly in policies

* fix: Policy serialization
This commit is contained in:
Tom Moor
2026-07-31 21:41:16 -04:00
committed by GitHub
parent a01b38665e
commit 23d20d749d
12 changed files with 245 additions and 65 deletions
+81 -13
View File
@@ -63,6 +63,47 @@ function invalidateChildPolicies(
}
}
/**
* Re-check the current user's access to a collection with the server and
* discard whatever they can no longer read. Abilities cannot be recalculated on
* the client, so access is never inferred from the cached policy.
*
* @param collectionId the ID of the collection access may have been lost to.
* @param stores the stores to remove the collection and its documents from.
*/
async function revokeCollectionAccess(
collectionId: string,
{
collections,
documents,
memberships,
policies,
}: Pick<RootStore, "collections" | "documents" | "memberships" | "policies">
) {
policies.remove(collectionId);
try {
await collections.fetch(collectionId, { force: true });
} catch (err) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
memberships.removeAll({ collectionId });
collections.remove(collectionId, { permanent: true });
} else {
Logger.error(
"Failed to fetch collection after access change",
toError(err)
);
}
return;
}
// Admins keep visibility of the collection itself, but may no longer be able
// to read the documents within it.
if (!policies.abilities(collectionId).readDocument) {
documents.removeInCollection(collectionId);
}
}
function useConnectionHandlers() {
const { auth } = useStores();
@@ -161,8 +202,8 @@ function useEntityHandlers() {
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
documents.remove(documentId);
return;
documents.remove(documentId, { permanent: true });
continue;
}
}
@@ -215,8 +256,8 @@ function useEntityHandlers() {
err instanceof NotFoundError
) {
memberships.removeAll({ collectionId });
collections.remove(collectionId);
return;
collections.remove(collectionId, { permanent: true });
continue;
}
}
}
@@ -266,7 +307,7 @@ function useDocumentHandlers() {
!document.collectionId &&
document.createdBy?.id !== currentUserId
) {
documents.remove(document.id);
documents.remove(document.id, { permanent: true });
} else {
documents.add(document);
}
@@ -313,7 +354,7 @@ function useDocumentHandlers() {
socket.on(
"documents.permanent_delete",
(event: WebsocketEntityDeletedEvent) => {
documents.remove(event.modelId);
documents.remove(event.modelId, { permanent: true });
}
);
@@ -352,7 +393,7 @@ function useDocumentHandlers() {
const policy = policies.get(event.documentId!);
if (policy && policy.abilities.read === false) {
documents.remove(event.documentId!);
documents.remove(event.documentId!, { permanent: true });
}
}
);
@@ -385,6 +426,11 @@ function useDocumentHandlers() {
"documents.remove_group",
(event: PartialExcept<GroupMembership, "id">) => {
groupMemberships.remove(event.id);
const policy = policies.get(event.documentId!);
if (policy && policy.abilities.read === false) {
documents.remove(event.documentId!, { permanent: true });
}
}
);
};
@@ -509,12 +555,16 @@ function useCollectionHandlers() {
}
});
socket.on("collections.remove_user", (event: Membership) => {
socket.on("collections.remove_user", async (event: Membership) => {
memberships.remove(event.id);
const policy = policies.get(event.collectionId);
if (policy && policy.abilities.read === false) {
collections.remove(event.collectionId);
if (event.userId === currentUserId) {
await revokeCollectionAccess(event.collectionId, {
collections,
documents,
memberships,
policies,
});
}
});
@@ -535,12 +585,30 @@ function useCollectionHandlers() {
socket.on("collections.remove_group", async (event: GroupMembership) => {
groupMemberships.remove(event.id);
// The event reaches everyone with access to the collection, so the policy
// narrows it to those that may have held it through the group.
const policy = policies.get(event.collectionId!);
if (policy && policy.abilities.read === false) {
collections.remove(event.collectionId!);
if (!policy || policy.abilities.read === false) {
await revokeCollectionAccess(event.collectionId!, {
collections,
documents,
memberships,
policies,
});
}
});
socket.on(
"collections.revoke_access",
async (event: WebsocketEntityDeletedEvent) =>
revokeCollectionAccess(event.modelId, {
collections,
documents,
memberships,
policies,
})
);
socket.on(
"collections.update_index",
action((event: WebsocketCollectionUpdateIndexEvent) => {
-8
View File
@@ -3,7 +3,6 @@ import type { CollectionPermission, DocumentPermission } from "@shared/types";
import Collection from "./Collection";
import Document from "./Document";
import Group from "./Group";
import { AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
import NavigableModel from "./base/NavigableModel";
@@ -57,13 +56,6 @@ class GroupMembership extends NavigableModel {
...options,
});
}
// hooks
@AfterRemove
public static removeFromPolicies(model: GroupMembership) {
model.store.rootStore.policies.removeForMembership(model.id);
}
}
export default GroupMembership;
-8
View File
@@ -3,7 +3,6 @@ import type { CollectionPermission } from "@shared/types";
import Collection from "./Collection";
import User from "./User";
import Model from "./base/Model";
import { AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
class Membership extends Model {
@@ -21,13 +20,6 @@ class Membership extends Model {
@observable
permission: CollectionPermission;
// hooks
@AfterRemove
public static removeFromPolicies(model: Membership) {
model.store.rootStore.policies.removeForMembership(model.id);
}
}
export default Membership;
-8
View File
@@ -4,7 +4,6 @@ import type UserMembershipsStore from "~/stores/UserMembershipsStore";
import Document from "./Document";
import User from "./User";
import Field from "./decorators/Field";
import { AfterRemove } from "./decorators/Lifecycle";
import Relation from "./decorators/Relation";
import NavigableModel from "./base/NavigableModel";
@@ -85,13 +84,6 @@ class UserMembership extends NavigableModel {
const index = memberships.indexOf(this);
return memberships[index + 1];
}
// hooks
@AfterRemove
public static removeFromPolicies(model: UserMembership) {
model.store.rootStore.policies.removeForMembership(model.id);
}
}
export default UserMembership;
+13
View File
@@ -191,6 +191,19 @@ export default class DocumentsStore extends Store<Document> {
return orderBy(this.inCollection(collectionId), "popularityScore", "desc");
}
/**
* Evict every document belonging to a collection from the store, for use when
* the current user has lost access to the collection's documents.
*
* @param collectionId the ID of the collection to evict documents for.
*/
@action
removeInCollection(collectionId: string) {
this.orderedData
.filter((document) => document.collectionId === collectionId)
.forEach((document) => this.remove(document.id, { permanent: true }));
}
get(id: string): Document | undefined {
return id
? (this.data.get(id) ??
+11
View File
@@ -21,6 +21,17 @@ export default class GroupMembershipsStore extends Store<GroupMembership> {
super(rootStore, GroupMembership);
}
/**
* Remove a membership, and the access that it granted.
*
* @param id the ID of the membership to remove.
*/
@action
remove(id: string, options?: { permanent?: boolean }): void {
super.remove(id, options);
this.rootStore.policies.removeForMembership(id);
}
@action
fetchPage = async ({
collectionId,
+11
View File
@@ -18,6 +18,17 @@ export default class MembershipsStore extends Store<Membership> {
super(rootStore, Membership);
}
/**
* Remove a membership, and the access that it granted.
*
* @param id the ID of the membership to remove.
*/
@action
remove(id: string, options?: { permanent?: boolean }): void {
super.remove(id, options);
this.rootStore.policies.removeForMembership(id);
}
@action
fetchPage = async (
params: (PaginationParams & { id?: string }) | undefined
+11
View File
@@ -18,6 +18,17 @@ export default class UserMembershipsStore extends Store<UserMembership> {
super(rootStore, UserMembership);
}
/**
* Remove a membership, and the access that it granted.
*
* @param id the ID of the membership to remove.
*/
@action
remove(id: string, options?: { permanent?: boolean }): void {
super.remove(id, options);
this.rootStore.policies.removeForMembership(id);
}
@action
fetchPage = async (params?: PaginationParams): Promise<UserMembership[]> => {
this.isFetching = true;
+11 -3
View File
@@ -171,8 +171,16 @@ export default abstract class Store<T extends Model> {
return item;
};
/**
* Remove a model, and any models that cascade from it, from the store.
*
* @param id the ID of the model to remove.
* @param options.permanent whether soft-deletable models should be evicted
* from the store entirely rather than marked as deleted. Use when the model
* is gone for good, or is no longer accessible to the current user.
*/
@action
remove(id: string): void {
remove(id: string, options?: { permanent?: boolean }): void {
const model = this.data.get(id);
if (!model) {
return;
@@ -195,7 +203,7 @@ export default abstract class Store<T extends Model> {
}
if (deleteBehavior === "cascade") {
store.remove(item.id);
store.remove(item.id, options);
} else if (deleteBehavior === "null") {
// @ts-expect-error TODO
item[relation.idKey] = null;
@@ -211,7 +219,7 @@ export default abstract class Store<T extends Model> {
LifecycleManager.executeHooks(model.constructor, "beforeRemove", model);
if (model instanceof ParanoidModel) {
if (model instanceof ParanoidModel && !options?.permanent) {
model.deletedAt = new Date().toISOString();
} else {
this.data.delete(id);
+30 -15
View File
@@ -11,11 +11,17 @@ type Policy = Record<string, boolean | string[]>;
/** Default so option-free calls share one object and stay cacheable. */
const noOptions: Readonly<Record<string, never>> = Object.freeze({});
/**
* The result of an ability condition. Strings are the IDs of the memberships
* that granted access, and may be nested in arrays by the `and` / `or` helpers.
*/
type ConditionResult = boolean | string | ConditionResult[];
type Condition<T extends Constructor, P extends Constructor> = (
performer: InstanceType<P>,
target: InstanceType<T> | null,
options?: unknown
) => boolean | string;
) => ConditionResult;
type Ability = {
model: Constructor;
@@ -281,28 +287,37 @@ export class CanCan {
);
// Check conditions only for matching abilities
const seenConditions = new Set<boolean | string>();
const membershipIds: string[] = [];
let hasNonMembershipMatch = false;
const collect = (result: ConditionResult) => {
if (!result) {
return;
}
if (typeof result === "string") {
if (!membershipIds.includes(result)) {
membershipIds.push(result);
}
return;
}
// Conditions are composed with the `and` / `or` helpers, which return the
// operands themselves rather than a boolean, so the membership IDs that
// granted access can be nested at any depth.
if (Array.isArray(result)) {
for (const value of result) {
collect(value);
}
return;
}
hasNonMembershipMatch = true;
};
for (const ability of matchingAbilities) {
if (!ability.condition) {
continue;
}
const result = ability.condition(performer, target, options);
if (!result || seenConditions.has(result)) {
continue;
}
seenConditions.add(result);
if (typeof result === "string") {
membershipIds.push(result);
} else {
hasNonMembershipMatch = true;
}
collect(ability.condition(performer, target, options));
}
return membershipIds.length > 0 ? membershipIds : hasNonMembershipMatch;
+47
View File
@@ -270,6 +270,53 @@ describe("private collection", () => {
});
});
describe("membership ids", () => {
it("should return the collection membership that grants access", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
const membership = await UserMembership.create({
collectionId: collection.id,
userId: user.id,
createdById: user.id,
permission: CollectionPermission.ReadWrite,
});
const doc = await buildDocument({
teamId: team.id,
collectionId: collection.id,
});
const document = await Document.findByPk(doc.id, { userId: user.id });
const abilities = serialize(user, document);
expect(abilities.read).toEqual([membership.id]);
expect(abilities.update).toEqual([membership.id]);
});
it("should return the document membership that grants access", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
permission: null,
});
const doc = await buildDocument({
teamId: team.id,
collectionId: collection.id,
});
const membership = await UserMembership.create({
documentId: doc.id,
userId: user.id,
createdById: user.id,
permission: DocumentPermission.Read,
});
const document = await Document.findByPk(doc.id, { userId: user.id });
const abilities = serialize(user, document);
expect(abilities.read).toEqual([membership.id]);
});
});
describe("no collection", () => {
it("should allow no permissions for team member", async () => {
const team = await buildTeam();
+30 -10
View File
@@ -203,11 +203,12 @@ export default class WebsocketsProcessor {
}
case "documents.permanent_delete": {
return socketio
.to(`collection-${event.collectionId}`)
.emit(event.name, {
modelId: event.documentId,
});
// The document is already gone, so the channels it was published to
// cannot be resolved. The payload is an ID only, so it is broadcast to
// the team to ensure everyone holding a copy discards it.
return socketio.to(`team-${event.teamId}`).emit(event.name, {
modelId: event.documentId,
});
}
case "documents.archive":
@@ -231,8 +232,19 @@ export default class WebsocketsProcessor {
},
paranoid: false,
});
// Documents are invalidated in both the source and destination
// collections members of the former may no longer have access to
// them. This is distinct from the collection invalidation below, which
// only refreshes the document structure.
const documentChannels = uniq(
concat(
event.data.collectionIds.map((id) => `collection-${id}`),
`collection-${event.collectionId}`
)
);
documents.forEach((document) => {
socketio.to(`collection-${document.collectionId}`).emit("entities", {
socketio.to(documentChannels).emit("entities", {
event: event.name,
invalidatedPolicies: [document.id],
documentIds: [
@@ -369,10 +381,6 @@ export default class WebsocketsProcessor {
// the collection became private, rebuild the channel from explicit
// memberships only.
if (attributes?.permission === null && previous?.permission) {
socketio
.in(`collection-${collection.id}`)
.socketsLeave(`collection-${collection.id}`);
const [memberships, groupMemberships] = await Promise.all([
UserMembership.findAll({
where: { collectionId: collection.id },
@@ -385,6 +393,18 @@ export default class WebsocketsProcessor {
...memberships.map((m) => `user-${m.userId}`),
...groupMemberships.map((m) => `group-${m.groupId}`),
];
// Everyone in the channel without an explicit membership has lost
// access to the documents within and must discard them.
socketio
.in(`collection-${collection.id}`)
.except(rooms)
.emit("collections.revoke_access", { modelId: collection.id });
socketio
.in(`collection-${collection.id}`)
.socketsLeave(`collection-${collection.id}`);
if (rooms.length) {
socketio.in(rooms).socketsJoin(`collection-${collection.id}`);
}