Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to use firestore rules on attributes (#6707) #6896

Merged
merged 2 commits into from
Feb 23, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions config/firestore.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"firestore": {
"database": "(default)",
"rules": "firestore.rules"
},
"auth": {
},
"emulators": {
"singleProjectMode": true,
"firestore": {
"host": "localhost",
"port": 8080
},
"auth": {
"host": "localhost",
"port": 9099
}
}
}
13 changes: 13 additions & 0 deletions config/firestore.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /public/{document=**} {
allow read, write: if true;
}
match /ownership/{document=**} {
allow read, delete: if request.auth != null && (resource == null || request.auth.uid == resource.data.owner);
allow create: if request.auth != null && request.auth.uid == request.resource.data.owner;
allow update: if request.auth != null && request.auth.uid == request.resource.data.owner && request.auth.uid == resource.data.owner;
}
}
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@
"test:browser:remote": " npm run transpile && cross-env CI=true DEFAULT_STORAGE=remote karma start ./config/karma.conf.cjs --single-run",
"test:browser:remote:loop": "npm run test:browser:remote && npm run test:browser:remote:loop",
"test:browser:custom": " npm run transpile && cross-env CI=true DEFAULT_STORAGE=custom karma start ./config/karma.conf.cjs --single-run",
"test:replication-firestore": "npm run transpile && firebase emulators:exec \"cross-env DEFAULT_STORAGE=dexie mocha --expose-gc --config ./config/.mocharc.cjs ./test_tmp/replication-firestore.test.js\" --only firestore --project 'rxdb-test'",
"test:replication-firestore": "npm run transpile && firebase emulators:exec \"cross-env DEFAULT_STORAGE=dexie mocha --expose-gc --config ./config/.mocharc.cjs ./test_tmp/replication-firestore.test.js\" --only firestore --config \"config/firestore.json\" --project \"rxdb-test\"",
"aaa": "firebase init firestore",
"test:replication-couchdb": "npm run transpile && concurrently \"npm run couch:start\" \"cross-env NATIVE_COUCHDB=5984 DEFAULT_STORAGE=dexie mocha --config ./config/.mocharc.cjs ./test_tmp/replication-couchdb.test.js\" --success first --kill-others",
"test:replication-nats": "npm run transpile && concurrently \"npm run nats:start\" \"cross-env DEFAULT_STORAGE=dexie mocha --config ./config/.mocharc.cjs ./test_tmp/replication-nats.test.js\" --success first --kill-others",
Expand Down Expand Up @@ -495,6 +495,7 @@
"@eslint/compat": "1.2.7",
"@eslint/eslintrc": "3.3.0",
"@eslint/js": "9.21.0",
"@firebase/rules-unit-testing": "4.0.1",
"@rollup/plugin-commonjs": "28.0.2",
"@rollup/plugin-node-resolve": "16.0.0",
"@stylistic/eslint-plugin": "4.0.1",
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/replication-firestore/firestore-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,5 @@ export function getContentByIds<RxDocType>(ids: string[], getQuery: GetQuery<RxD
}

// after all of the data is fetched, return it
return Promise.all(batches).then((content) => content.map(i => i.docs).flat());
return Promise.all(batches).then((content) => content.flat());
}
3 changes: 2 additions & 1 deletion src/plugins/replication-firestore/firestore-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
import type {
CollectionReference,
Firestore,
QueryDocumentSnapshot,
QueryFieldFilterConstraint,
QuerySnapshot
} from 'firebase/firestore';
Expand Down Expand Up @@ -67,4 +68,4 @@ export type SyncOptionsFirestore<RxDocType> = Omit<
push?: FirestoreSyncPushOptions<RxDocType>;
};

export type GetQuery<RxDocType> = (ids: string[]) => Promise<QuerySnapshot<RxDocType>>;
export type GetQuery<RxDocType> = (ids: string[]) => Promise<QueryDocumentSnapshot<RxDocType>[]>;
20 changes: 18 additions & 2 deletions src/plugins/replication-firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import {
orderBy,
limit,
getDocs,
getDoc,
onSnapshot,
runTransaction,
writeBatch,
serverTimestamp,
QueryDocumentSnapshot,
waitForPendingWrites,
documentId
documentId,
FirestoreError
} from 'firebase/firestore';

import { RxDBLeaderElectionPlugin } from '../leader-election/index.ts';
Expand Down Expand Up @@ -257,7 +259,21 @@ export function replicateFirestore<RxDocType>(
options.firestore.collection,
where(documentId(), 'in', ids)
)
);
)
.then(result => result.docs)
.catch(error => {
if (error?.code && (error as FirestoreError).code === 'permission-denied') {
// Query may fail due to rules using 'resource' with non existing ids
// So try to get the docs one by one
return Promise.all(
ids.map(
id => getDoc(doc(options.firestore.collection, id))
)
)
.then(docs => docs.filter(doc => doc.exists()));
}
throw error;
});
};

const docsInDbResult = await getContentByIds<RxDocType>(docIds, getQuery);
Expand Down
36 changes: 36 additions & 0 deletions src/plugins/test-utils/humans-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,3 +518,39 @@ export async function createIdAndAgeIndex(

return collections.humana;
}

export async function createHumanWithOwnership(
amount = 20,
databaseName = randomToken(10),
multiInstance = true,
owner = "alice",
storage = getConfig().storage.getStorage(),
conflictHandler?: RxConflictHandler<any>,

): Promise<RxCollection<schemaObjects.HumanWithOwnershipDocumentType>> {

const db = await createRxDatabase<{ humans: RxCollection<schemaObjects.HumanWithOwnershipDocumentType>; }>({
name: databaseName,
storage,
multiInstance,
eventReduce: true,
ignoreDuplicate: true
});
// setTimeout(() => db.close(), dbLifetime);
const collections = await db.addCollections({
humans: {
conflictHandler,
schema: schemas.humanWithOwnership
}
});

// insert data
if (amount > 0) {
const docsData = new Array(amount)
.fill(0)
.map(() => schemaObjects.humanWithOwnershipData({}, owner));
await collections.humans.bulkInsert(docsData);
}

return collections.humans;
}
21 changes: 21 additions & 0 deletions src/plugins/test-utils/schema-objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,3 +505,24 @@ export function humanWithCompositePrimary(partial: Partial<HumanWithCompositePri
partial
);
}

export type HumanWithOwnershipDocumentType = {
passportId: string;
firstName: string;
lastName: string;
age: number;
owner?: string;
};
export function humanWithOwnershipData(partial: Partial<HumanWithOwnershipDocumentType> = {}, owner: string): HumanWithOwnershipDocumentType {
const defaultObj = {
passportId: randomStringWithSpecialChars(8, 12),
firstName: randomStringWithSpecialChars(8, 12),
lastName: randomStringWithSpecialChars(8, 12),
age: randomNumber(10, 50),
owner
};
return Object.assign(
defaultObj,
partial
);
}
36 changes: 36 additions & 0 deletions src/plugins/test-utils/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,42 @@ export const humanIdAndAgeIndex: RxJsonSchema<{ id: string; name: string; age: n
]
});

export const humanWithOwnership: RxJsonSchema<HumanDocumentType> = overwritable.deepFreezeWhenDevMode({
title: 'human schema',
version: 0,
description: 'describes a human being',
keyCompression: false,
primaryKey: 'passportId',
type: 'object',
properties: {
passportId: {
type: 'string',
maxLength: 100
},
firstName: {
type: 'string',
maxLength: 100
},
lastName: {
type: 'string',
maxLength: 100
},
age: {
description: 'age in years',
type: 'integer',
minimum: 0,
maximum: 150,
default: 20
},
owner: {
type: 'string',
maxLength: 128
}
},
indexes: [],
required: ['passportId']
});


export function enableKeyCompression<RxDocType>(
schema: RxJsonSchema<RxDocType>
Expand Down
38 changes: 32 additions & 6 deletions test/replication-firestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ import {
where,
orderBy,
limit,
getDoc
getDoc,
QueryConstraint
} from 'firebase/firestore';
import {
FirestoreOptions,
Expand All @@ -68,24 +69,27 @@ describe('replication-firestore.test.ts', function () {
*/
const batchSize = 5;
type TestDocType = HumanWithTimestampDocumentType;
async function getAllDocsOfFirestore(firestore: FirestoreOptions<TestDocType>): Promise<TestDocType[]> {
const result = await getDocs(query(firestore.collection));
async function getAllDocsOfFirestore<DT = TestDocType>(firestore: FirestoreOptions<DT>, ...criteria: QueryConstraint[]): Promise<DT[]> {
const result = await getDocs(query(firestore.collection, ...criteria));
return result.docs.map(d => {
const docData = d.data();
(docData as any).id = d.id;
return docData;
}) as any;
}
const projectId = randomToken(10);
const ownerUid = 'owner1';

config.storage.init?.();
const app = firebase.initializeApp({
projectId,
databaseURL: 'http://localhost:8080?ns=' + projectId
});
const database = getFirestore(app);
connectFirestoreEmulator(database, 'localhost', 8080);
connectFirestoreEmulator(database, 'localhost', 8080, { mockUserToken: { user_id: ownerUid }});

function getFirestoreState(): FirestoreOptions<TestDocType> {
const useCollection: CollectionReference<TestDocType> = getFirestoreCollection(database, randomToken(10)) as any;
function getFirestoreState(rootCollection = 'public'): FirestoreOptions<TestDocType> {
const useCollection: CollectionReference<TestDocType> = getFirestoreCollection(database, rootCollection, randomToken(10), randomToken(10)) as any;
return {
projectId,
collection: useCollection,
Expand Down Expand Up @@ -459,6 +463,28 @@ describe('replication-firestore.test.ts', function () {

collection.database.close();
});
it('#6707 firestore replication this owner rules', async () => {
const collection1 = await humansCollection.createHumanWithOwnership(2, undefined, false, ownerUid);
const firestoreState = getFirestoreState('ownership');
const replicationState = replicateFirestore({
replicationIdentifier: firestoreState.projectId,
firestore: firestoreState,
collection: collection1,
pull: {
filter: [
where('owner', '==', ownerUid),
],
},
push: {},
live: true,
autoStart: true
});
ensureReplicationHasNoErrors(replicationState);
await replicationState.awaitInitialReplication();

const docsOnServer = await getAllDocsOfFirestore(firestoreState, where('owner', '==', ownerUid));
assert.strictEqual(docsOnServer.length, 2);
});

});
});