From 35433932733431c92c039be5faa7bb24a4224e1e Mon Sep 17 00:00:00 2001 From: Zio-4 Date: Wed, 8 Oct 2025 12:40:58 -0700 Subject: [PATCH] Updating the publish action to publish to NPM and submit a PR on the dashboard. --- .DS_Store | Bin 6148 -> 6148 bytes .github/workflows/doc.yml | 28 -- .github/workflows/publish.yml | 113 ++++-- .github/workflows/submit-dashboard-pr.yml | 49 --- firebase/.DS_Store | Bin 6148 -> 0 bytes firebase/admin/firebase.json | 33 -- firebase/admin/firestore.indexes.json | 0 firebase/admin/firestore.rules | 452 --------------------- firebase/assessment/firebase.json | 33 -- firebase/assessment/firestore.indexes.json | 23 -- firebase/assessment/firestore.rules | 419 ------------------- 11 files changed, 85 insertions(+), 1065 deletions(-) delete mode 100644 .github/workflows/doc.yml delete mode 100644 .github/workflows/submit-dashboard-pr.yml delete mode 100644 firebase/.DS_Store delete mode 100644 firebase/admin/firebase.json delete mode 100644 firebase/admin/firestore.indexes.json delete mode 100644 firebase/admin/firestore.rules delete mode 100644 firebase/assessment/firebase.json delete mode 100644 firebase/assessment/firestore.indexes.json delete mode 100644 firebase/assessment/firestore.rules diff --git a/.DS_Store b/.DS_Store index 4940c330d06c5a3eb07a73ea392dc00ac801e865..466e01d1a8cf779322e550b404bb81fb3e37f1f3 100644 GIT binary patch delta 32 ocmZoMXfc@J&nU7nU^g?P$YvfEZpO{)StFPx7R=bp&heKY0Ha|FC;$Ke delta 179 zcmZoMXfc@J&nU4mU^g?P#AY5AZpL~JhBSsuh9ZVkh9rhWhGK@)oOHwB1KKNs diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml deleted file mode 100644 index 9642d5ba..00000000 --- a/.github/workflows/doc.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Build docs -on: - push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' -jobs: - build-and-deploy: - concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. - runs-on: ubuntu-latest - steps: - - name: Checkout 🛎️ - uses: actions/checkout@v3 - - name: Use Node.js 17 - uses: actions/setup-node@v2 - with: - node-version: 'lts/*' - cache: 'npm' - - name: Install and Build 🔧 - run: | - npm ci - npm run build - npm run format - npm run doc - - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@v4.2.5 - with: - branch: gh-pages # The branch the action should deploy to. - folder: docs # The folder the action should deploy. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 45da6919..50f3a5f9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,36 +1,93 @@ -name: Publish Package to npmjs +name: Publish Firekit + on: push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - - 'v[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+' - - 'v[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+' + branches: + - main + +permissions: + contents: write + id-token: write + pull-requests: write + jobs: - build: + publish: runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.read_version.outputs.version }} steps: - - name: Checkout 🛎️ - uses: actions/checkout@v3 - # Setup .npmrc file to publish to npm - - uses: actions/setup-node@v2 + - name: Checkout repository + uses: actions/checkout@v4 with: - node-version: '17.x' + fetch-depth: 0 + token: ${{ secrets.GIT_BOT_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' registry-url: 'https://registry.npmjs.org' - - name: Install and Build 🔧 + + - name: Update npm + run: npm install -g npm@latest + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build --if-present + + - name: Test + run: npm test --if-present + + - name: Publish package + run: npm publish --access public --provenance + + - name: Read published version + id: read_version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + update-dashboard: + needs: publish + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: generate_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.LEVANTE_BOT_APP_ID }} + private_key: ${{ secrets.LEVANTE_BOT_APP_PRIVATE_KEY }} + repository: levante-framework/levante-dashboard + + - name: Checkout dashboard repository + uses: actions/checkout@v4 + with: + repository: levante-framework/levante-dashboard + token: ${{ steps.generate_token.outputs.token }} + path: dashboard + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Update Firekit dependency + working-directory: dashboard run: | - npm ci - npm run build - npm run format - - name: Publish 🚀 - run: npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - ROAR_CI_USER_EMAIL: ${{ secrets.ROAR_CI_USER_EMAIL }} - ROAR_CI_USER_PASSWORD: ${{ secrets.ROAR_CI_USER_PASSWORD }} - ROAR_FIREBASE_API_KEY: ${{ secrets.ROAR_FIREBASE_API_KEY }} - ROAR_FIREBASE_APP_ID: ${{ secrets.ROAR_FIREBASE_APP_ID }} - ROAR_FIREBASE_MEASUREMENT_ID: ${{ secrets.ROAR_FIREBASE_MEASUREMENT_ID }} - ROAR_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.ROAR_FIREBASE_MESSAGING_SENDER_ID }} - ROAR_FIREBASE_AUTH_DOMAIN: ${{ secrets.ROAR_FIREBASE_AUTH_DOMAIN }} - ROAR_FIREBASE_PROJECT_ID: ${{ secrets.ROAR_FIREBASE_PROJECT_ID }} - ROAR_FIREBASE_STORAGE_BUCKET: ${{ secrets.ROAR_FIREBASE_STORAGE_BUCKET }} + npm install @levante-framework/firekit@${{ needs.publish.outputs.new_version }} + + - name: Create pull request + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ steps.generate_token.outputs.token }} + path: dashboard + branch: chore/update-firekit-${{ needs.publish.outputs.new_version }} + commit-message: "chore: update firekit to ${{ needs.publish.outputs.new_version }}" + title: "chore: update firekit to ${{ needs.publish.outputs.new_version }}" + body: | + ## Summary + - install @levante-framework/firekit@${{ needs.publish.outputs.new_version }} + + Generated automatically after publishing firekit. diff --git a/.github/workflows/submit-dashboard-pr.yml b/.github/workflows/submit-dashboard-pr.yml deleted file mode 100644 index a75903cc..00000000 --- a/.github/workflows/submit-dashboard-pr.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Update dependency version in ROAR-dashboard - -on: - workflow_run: - workflows: ['Publish Package to npmjs'] - types: - - completed - -jobs: - update-version: - runs-on: ubuntu-latest - - if: ${{ github.event.workflow_run.conclusion == 'success' }} - - steps: - - name: Checkout the repository - uses: actions/checkout@v2 - - - name: Get the new version - id: get_new_version - run: | - VERSION=$(jq -r '.version' package.json) - echo "NEW_VERSION=$VERSION" >> $GITHUB_OUTPUT - - - name: Checkout the target repository - uses: actions/checkout@v2 - with: - repository: yeatmanlab/roar-dashboard - token: ${{ secrets.DASHBOARD_REPO_TOKEN }} - path: dashboard-repo - - - name: Update version in package.json - run: | - cd dashboard-repo - NEW_VERSION=${{ steps.get_new_version.outputs.NEW_VERSION }} - jq --arg ver "$NEW_VERSION" '.dependencies["@bdelab/roar-firekit"] = $ver' package.json > temp.json && mv temp.json package.json - npm i - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v4 - with: - path: dashboard-repo - token: ${{ secrets.DASHBOARD_REPO_TOKEN }} - commit-message: Update firekit version to ${{ steps.get_new_version.outputs.NEW_VERSION }} - branch: dep/update-firekit-${{ steps.get_new_version.outputs.NEW_VERSION }} - base: main - title: Update firekit version to ${{ steps.get_new_version.outputs.NEW_VERSION }} - body: | - This PR updates the version of `@bdelab/roar-firekit` to ${{ steps.get_new_version.outputs.NEW_VERSION }}. diff --git a/firebase/.DS_Store b/firebase/.DS_Store deleted file mode 100644 index 4d0da38452e3a3312e466b8d876d12cbbf82f163..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKQAz_r3{7fk(bh-uge{bRudJ6C1LHzJ#64q|F zrGFwNDU&ywNoHo>44a0Cc=TQli3UWJp$Ybmpf-q%OY2C_EHcPxj`{3qIDV+&qT}m^ z|H*)y-67pmNs(S??fmX8+oQ26is`JH!kR75zi&U5pI7T1YlzXnmuitGgiO5+vVHdr(b9}C>LkI8Q2>Jkh4Xy6Ghj~fHU9> zGz`f1A)pE7hP7h)bU>vN0N95)3Hnk?NK7!y4QoY=Kv+Y88p>8;u!h4P%r7^r6*ZjL ziVwDx*(wxHt7HC<+=+8V*Uo@5&}QI39|w~EueaC#?I3@02AqMtVu1U_xESG;EVp)E vPI7HPo1lq^U#-}Lpp#NDd?gj1p(TMmNC%i3){3w|{EtAQ!Id-crwsf63h+~Z diff --git a/firebase/admin/firebase.json b/firebase/admin/firebase.json deleted file mode 100644 index 57acc0ea..00000000 --- a/firebase/admin/firebase.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "firestore": { - "rules": "firestore.rules", - "indexes": "firestore.indexes.json" - }, - "emulators": { - "auth": { - "host": "127.0.0.1", - "port": 9099 - }, - "firestore": { - "host": "127.0.0.1", - "port": 8080 - }, - "functions": { - "host": "127.0.0.1", - "port": 5001 - }, - "ui": { - "host": "127.0.0.1", - "port": 4000 - }, - "hub": { - "host": "127.0.0.1", - "port": 4400 - }, - "logging": { - "host": "127.0.0.1", - "port": 4500 - }, - "singleProjectMode": false - } -} diff --git a/firebase/admin/firestore.indexes.json b/firebase/admin/firestore.indexes.json deleted file mode 100644 index e69de29b..00000000 diff --git a/firebase/admin/firestore.rules b/firebase/admin/firestore.rules deleted file mode 100644 index 3afa8252..00000000 --- a/firebase/admin/firestore.rules +++ /dev/null @@ -1,452 +0,0 @@ -rules_version = '2'; -service cloud.firestore { - match /databases/{database}/documents { - // Allow super_admins to do everything - match /{everythingInMyDatabase=**} { - allow read, write: if request.auth.token.get("super_admin", false) == true; - } - - function loggedIn() { - return request.auth != null; - } - - function roarUid() { - return request.auth.token.get("roarUid", ""); - } - - // The auth token has a custom claim for the organizations that the user is - // an admin for. The expected data structure is - // token.adminOrgs = { - // districts?: string[], - // schools?: string[], - // classes?: string[], - // families?: string[], - // groups?: string[], - // } - function getAdminList(orgType) { - return request.auth.token.get("adminOrgs", {}).get(orgType, []).toSet(); - } - - function targetOrgsInAdminList(orgType, targetOrgIds) { - return targetOrgIds.size() > 0 && getAdminList(orgType).hasAny(targetOrgIds); - } - - function keysNotUpdated(keys) { - return (!request.resource.data.diff(resource.data).affectedKeys().hasAny(keys)); - } - - function onlyTheseKeysUpdated(keys) { - return request.resource.data.diff(resource.data).affectedKeys().hasOnly(keys); - } - - // We use the userClaims collection to store refresh timestamps to propogate - // custom user claims back to the client. - match /userClaims/{uid} { - allow read: if loggedIn() && uid == request.auth.uid; - allow write: if false; // Only write in cloud functions using admin SDK - } - - // Allow all users to read the legal docs - // Allow no users to write the legal docs - match /legal/{form} { - allow read: if true; - allow write: if false; - } - - // Allow users to read their own data - // Also allow admins to read and write data for their users - match /users/{uid} { - function myData() { - return loggedIn() && uid == roarUid(); - } - - function isCurrentOrPreviousAdmin() { - return (targetOrgsInAdminList('districts', resource.data.get(['districts', 'all'], [])) - || targetOrgsInAdminList('schools', resource.data.get(['schools', 'all'], [])) - || targetOrgsInAdminList('classes', resource.data.get(['classes', 'all'], [])) - || targetOrgsInAdminList('families', resource.data.get(['families', 'all'], [])) - || targetOrgsInAdminList('groups', resource.data.get(['groups', 'all'], []))); - } - - function canReadExistingUser() { - return loggedIn() && (myData() || isCurrentOrPreviousAdmin()); - } - - allow read: if canReadExistingUser(); - - // We now address creating a new user: - // If the authenticated user (requestor) is creating a new user (target), then - // the requestor must satisfy one of the following conditions - // - the requestor is a district admin for the districtId of the target. The schoolId and classId must be in that district. And families and groups must be empty. - // - the requestor is a school admin for the schoolId of the target. The districtId of the target must match that of the school. And the classId must be in the school. - // - the requestor is a class admin for the classId of the target. The districtId and schoolId of the target must match that of the class. - // - the requestor is a family admin and the target is in the same family. No educational orgs can be set - // - the requestor is a group admin for the groupId of the target. No educational orgs can be set - - function commonKeys() { - return ['userType', 'name', 'assessmentPid', 'studentData', 'educatorData', 'caregiverData', 'adminData']; - } - - function educationalOrgKeys() { - return ['classes', 'schools', 'districts']; - } - - function allowedEduKeys(newUser) { - let allowedKeys = commonKeys().concat(educationalOrgKeys()); - return newUser ? allowedKeys.concat(['assessmentUid']) : allowedKeys; - } - - function allowedFamilyKeys(newUser) { - let allowedKeys = commonKeys().concat(['families']); - return newUser ? allowedKeys.concat(['assessmentUid']) : allowedKeys; - } - - function allowedGroupKeys(newUser) { - let allowedKeys = commonKeys().concat(['groups']); - return newUser ? allowedKeys.concat(['assessmentUid']) : allowedKeys; - } - - function readOnlyKeys() { - return ['archived', 'assessmentUid'] - } - - function userUpdateKeys() { - return [ - 'assessmentsCompleted', - 'assessmentsAssigned', - 'consent', - 'legal' - ] - } - - function requestHasOnlyEduKeys(newUser) { - return request.resource.data.keys().hasOnly(allowedEduKeys(newUser)); - } - - function requestHasOnlyGroupKeys(newUser) { - return request.resource.data.keys().hasAny(allowedGroupKeys(newUser)); - } - - function requestHasOnlyFamilyKeys(newUser) { - return request.resource.data.keys().hasAny(allowedFamilyKeys(newUser)); - } - - function noPreviousOrgsOfThisType(orgType) { - let orgData = request.resource.data.get(orgType, {}); - let currentIds = orgData.get('current', []); - return orgData.keys().size() > 0 && currentIds.size() > 0 && orgData.get('all', []).hasOnly(currentIds) && orgData.get('dates', {}).keys().hasOnly(currentIds); - } - - function requestHasOnlyCurrentOrgs() { - return noPreviousOrgsOfThisType('districts') - && noPreviousOrgsOfThisType('schools') - && noPreviousOrgsOfThisType('classes') - && noPreviousOrgsOfThisType('groups') - && noPreviousOrgsOfThisType('families'); - } - - function atMostOneOrgInRequest(orgType) { - return request.resource.data.get([orgType, 'current'], []).size() <= 1; - } - - function atMostOneDistrictAndSchoolInRequest() { - return atMostOneOrgInRequest('districts') && atMostOneOrgInRequest('schools'); - } - - function getOrgDoc(orgType, orgId) { - return get(/databases/$(database)/documents/$(orgType)/$(orgId)).data - } - - function orgHasMatchingKey(orgType, orgId, key, valueToMatch) { - return getOrgDoc(orgType, orgId).get(key, 'nullId') == valueToMatch; - } - - function requestOrgsInAdminList(orgType) { - return targetOrgsInAdminList(orgType, request.resource.data.get([orgType, 'current'], [])); - } - - function isClassAdminForNewUser() { - let data = request.resource.data; - let currentDistrict = data.get(['districts', 'current'], ['nullId'])[0]; - let currentSchool = data.get(['schools', 'current'], ['nullId'])[0]; - let currentClass = data.get(['classes', 'current'], ['nullId'])[0]; - let currentClassDoc = getOrgDoc('classes', currentClass); - - return (requestOrgsInAdminList('classes') - && atMostOneOrgInRequest('classes') - && currentClassDoc.get('districtId', 'nullId') == currentDistrict - && currentClassDoc.get('schoolId', 'nullId') == currentSchool); - } - - function isSchoolAdminForNewUser() { - let data = request.resource.data; - let currentDistrict = data.get(['districts', 'current'], ['nullId'])[0]; - let currentSchool = data.get(['schools', 'current'], ['nullId'])[0]; - let currentClasses = data.get(['classes', 'current'], []); - let currentSchoolDoc = getOrgDoc('schools', currentSchool); - - return (requestOrgsInAdminList('schools') - && orgHasMatchingKey('schools', currentSchool, 'districtId', currentDistrict)) - && currentClasses.hasOnly(currentSchoolDoc.get('classes', [])) - } - - function isDistrictAdminForNewUser() { - // TODO: Make sure all of the classes are also in the district - // In order to accomplish this, I think we may need to record class IDs in the district doc - let data = request.resource.data; - let currentDistrict = data.get(['districts', 'current'], ['nullId'])[0]; - let currentSchools = data.get(['schools', 'current'], []); - let currentDistrictDoc = getOrgDoc('districts', currentDistrict); - - return (requestOrgsInAdminList('districts') - && currentSchools.hasOnly(currentDistrictDoc.get('schools', []))); - } - - function isEduAdminForNewUser() { - // Add 'assessmentUid' to the list of allowed keys only for user doc creation - return requestHasOnlyEduKeys(true) && (isDistrictAdminForNewUser() || isSchoolAdminForNewUser() || isClassAdminForNewUser()); - } - - function isAdminForNewUser() { - let familyAdminCondition = (requestOrgsInAdminList('families') && requestHasOnlyFamilyKeys(true)); - let groupAdminCondition = (requestOrgsInAdminList('groups') && requestHasOnlyGroupKeys(true)); - - return (familyAdminCondition || groupAdminCondition || isEduAdminForNewUser()); - } - - function canCreateUser() { - return loggedIn() && isAdminForNewUser() && requestHasOnlyCurrentOrgs() && atMostOneDistrictAndSchoolInRequest(); - } - - allow create: if canCreateUser(); - - // We now address updating an existing user - // If the authenticated user (requestor) is updating an existing user (target), then - // the requestor must satisfy one of the following conditions - // - the requestor is a district admin for the districtId of the target. They can only update common + educational keys. Any added schools and classes must be in the district. - // - the requestor is a school admin for the schoolId of the target. They may not update district info. They can only update common + educational keys. Any added classes must be in the school. - // - the requestor is a class admin for the classId of the target. The districtId and schoolId of the target must match that of the class. - // - the requestor is a family admin for a current family of the target. No educational orgs can be set - // - the requestor is a group admin for a current group of the target. No educational orgs can be set - - function resourceOrgsInAdminList(orgType) { - return targetOrgsInAdminList(orgType, resource.data.get([orgType, 'current'], [])); - } - - function orgTypeIsConsistent(orgType) { - let data = request.resource.data; - return data.get([orgType, 'all'], []).hasAll(data.get([orgType, 'current'], [])); - } - - function orgsAreConsistent() { - return orgTypeIsConsistent('districts') - && orgTypeIsConsistent('schools') - && orgTypeIsConsistent('classes') - && orgTypeIsConsistent('families') - && orgTypeIsConsistent('groups'); - } - - function isDistrictAdmin() { - return resourceOrgsInAdminList('districts') && keysNotUpdated(['districts']); - } - - function isSchoolAdmin() { - return resourceOrgsInAdminList('schools') && keysNotUpdated(['districts', 'schools']); - } - - function isClassAdmin() { - return resourceOrgsInAdminList('classes') && keysNotUpdated(['districts', 'schools', 'classes']); - } - - function isEduAdmin() { - return requestHasOnlyEduKeys(false) - && atMostOneDistrictAndSchoolInRequest() - && keysNotUpdated(['name']) - && (isDistrictAdmin() || isSchoolAdmin() || isClassAdmin()) - } - - function isFamilyAdmin() { - return resourceOrgsInAdminList('families') && requestHasOnlyFamilyKeys(false) - } - - function isGroupAdmin() { - return resourceOrgsInAdminList('groups') && requestHasOnlyGroupKeys(false) - } - - function isCurrentAdmin() { - return keysNotUpdated(readOnlyKeys().concat(userUpdateKeys())) && (isEduAdmin() || isFamilyAdmin() || isGroupAdmin()) - } - - function editingMyData() { - return myData() && onlyTheseKeysUpdated(userUpdateKeys()); - } - - allow update: if (isCurrentAdmin() || editingMyData()) && orgsAreConsistent(); - - match /externalData/{externalDataId} { - // These are versions of the above rules with database reads. This is - // needed because resource.data for externalData documents will not contain - // `districts`, `classes`, etc. So we must read the parent document first. - function isCurrentOrPreviousAdminWithDbRead() { - let targetUser = get(/databases/$(database)/documents/users/$(uid)).data; - return (targetOrgsInAdminList('districts', targetUser.get(['districts', 'all'], [])) - || targetOrgsInAdminList('schools', targetUser.get(['schools', 'all'], [])) - || targetOrgsInAdminList('classes', targetUser.get(['classes', 'all'], [])) - || targetOrgsInAdminList('families', targetUser.get(['families', 'all'], [])) - || targetOrgsInAdminList('groups', targetUser.get(['groups', 'all'], []))); - } - - function canReadExistingUserWithDbRead() { - return loggedIn() && (myData() || isCurrentOrPreviousAdminWithDbRead()); - } - - allow read: if canReadExistingUserWithDbRead(); - - // Allow only reads under the assumption that external data writes will - // be performed in cloud functions with the admin SDK. - allow write: if false; - } - - // Users should be able to read and write to their own assignments - // Admins should be able to read (not write) assignments if they are an admin for one of the assigning orgs - // N.B. This assumes that the assigningOrgs are exhaustively listed. E.g., if district 1 assigns an administration, - // then schools A and B, which are in district 1, are assumed to also be listed in assigningOrgs. - match /assignments/{administrationId} { - function isAdminForAssigningOrg(orgType) { - return targetOrgsInAdminList(orgType, resource.data.get(['assigningOrgs', orgType], [])); - } - - function isAdminForAnyAssigningOrg() { - return (isAdminForAssigningOrg('districts') - || isAdminForAssigningOrg('schools') - || isAdminForAssigningOrg('classes') - || isAdminForAssigningOrg('groups') - || isAdminForAssigningOrg('families')); - } - - function canReadAssignment() { - return loggedIn() && (myData() || isAdminForAnyAssigningOrg()); - } - - function lengthOfAssessmentsUnchanged() { - return request.resource.data.get('assessments', []).size() == resource.data.get('assessments', []).size(); - } - - function canUpdateAssignment() { - return myData() && lengthOfAssessmentsUnchanged() && keysNotUpdated(['assigningOrgs']); - } - - allow read: if canReadAssignment(); - allow create: if false; // Only allow assignment creation in cloud functions using the admin SDK. - allow update: if canUpdateAssignment(); - } - } - - // Allow users to read any administration that - // - they created - // - they are assigned to - // - they are an admin for any of the assigned organizations. - // Allow users to create or update administrations only if - // - they are recorded as the creator - // - they are an admin for any of the assigned organizations. - // Prohibit deletion by anyone except super_admins. - // Prohibit modification of the createdBy field. - // N.B. This assumes that the assigningOrgs are exhaustively listed. E.g., if district 1 assigns an administration, - // then schools A and B, which are in district 1, are assumed to also be listed in assigningOrgs. - match /administrations/{administrationId}/{document=**} { - function userAssignedToAdministration() { - let userData = get(/databases/$(database)/documents/users/$(roarUid())).data; - return userData.get(['districts', 'all'], []).toSet().hasAny(resource.data.districts) - || userData.get(['schools', 'all'], []).toSet().hasAny(resource.data.schools) - || userData.get(['classes', 'all'], []).toSet().hasAny(resource.data.classes) - || userData.get(['groups', 'all'], []).toSet().hasAny(resource.data.groups) - || userData.get(['families', 'all'], []).toSet().hasAny(resource.data.families); - } - - function userCreatedExistingAdministration() { - return roarUid() == resource.data.createdBy; - } - - function userCreatedNewAdministration() { - return roarUid() == request.resource.data.createdBy; - } - - function isAdminForAnyAssignedOrg() { - return targetOrgsInAdminList('districts', resource.data.districts) - || targetOrgsInAdminList('schools', resource.data.schools) - || targetOrgsInAdminList('classes', resource.data.classes) - || targetOrgsInAdminList('groups', resource.data.groups) - || targetOrgsInAdminList('families', resource.data.families); - } - - function isAdminForAnyAssignedOrgInNewAdministration() { - return targetOrgsInAdminList('districts', request.resource.data.districts) - || targetOrgsInAdminList('schools', request.resource.data.schools) - || targetOrgsInAdminList('classes', request.resource.data.classes) - || targetOrgsInAdminList('groups', request.resource.data.groups) - || targetOrgsInAdminList('families', request.resource.data.families); - } - - function canRead() { - return userAssignedToAdministration() || userCreatedExistingAdministration() || isAdminForAnyAssignedOrg(); - } - - allow read: if loggedIn() && canRead(); - - function canUpdate() { - return userCreatedExistingAdministration() || isAdminForAnyAssignedOrg(); - } - - function canCreate() { - return userCreatedNewAdministration() && isAdminForAnyAssignedOrgInNewAdministration(); - } - - allow create: if loggedIn() && canCreate(); - allow update: if loggedIn() && canUpdate() && keysNotUpdated(['createdBy']); - allow delete: if false; - } - - function getAllOrgs(orgType) { - return get(/databases/$(database)/documents/users/$(roarUid())).data.get([orgType, 'all'], []); - } - - // Anyone in a district can read that district's info - // Only super_admins can write districts. - match /districts/{districtId} { - allow read: if loggedIn() && getAllOrgs('districts').hasAll([districtId]); - allow write: if false; - } - - // Anyone in a school can read that school's info. - // Only district level admins can write to schools if the school is in their district. - match /schools/{schoolId} { - allow read: if loggedIn() && getAllOrgs('schools').hasAll([schoolId]); - allow create: if targetOrgsInAdminList('districts', [request.resource.data.get('districtId', 'nullId')]); - allow update, delete: if targetOrgsInAdminList('districts', [resource.data.get('districtId', 'nullId')]); - } - - // Anyone in a class can read that class's info - // Only school or district level admins can write to classes if the class is in their school or district. - match /classes/{classId} { - allow read: if loggedIn() && getAllOrgs('classes').hasAll([classId]); - allow create: if targetOrgsInAdminList('districts', [request.resource.data.get('districtId', 'nullId')]) - || targetOrgsInAdminList('schools', [request.resource.data.get('schoolId', 'nullId')]); - allow update, delete: if targetOrgsInAdminList('districts', [resource.data.get('districtId', 'nullId')]) - || targetOrgsInAdminList('schools', [resource.data.get('schoolId', 'nullId')]); - } - - // Anyone in a group can read that group's info - // Anyone who is an admin for this group can write to it - match /groups/{groupId} { - allow read: if loggedIn() && getAllOrgs('groups').hasAll([groupId]); - allow write: if targetOrgsInAdminList('groups', [groupId]); - } - - // Anyone in a family can read that family's info - // Any admin for this family can write to it - match /families/{familyId} { - allow read: if loggedIn() && getAllOrgs('families').hasAll([familyId]); - allow write: if targetOrgsInAdminList('families', [familyId]); - } - } -} diff --git a/firebase/assessment/firebase.json b/firebase/assessment/firebase.json deleted file mode 100644 index df7f61ca..00000000 --- a/firebase/assessment/firebase.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "firestore": { - "rules": "firestore.rules", - "indexes": "firestore.indexes.json" - }, - "emulators": { - "auth": { - "host": "127.0.0.1", - "port": 9098 - }, - "firestore": { - "host": "127.0.0.1", - "port": 8079 - }, - "functions": { - "host": "127.0.0.1", - "port": 5000 - }, - "ui": { - "host": "127.0.0.1", - "port": 3999 - }, - "hub": { - "host": "127.0.0.1", - "port": 4399 - }, - "logging": { - "host": "127.0.0.1", - "port": 4499 - }, - "singleProjectMode": false - } -} diff --git a/firebase/assessment/firestore.indexes.json b/firebase/assessment/firestore.indexes.json deleted file mode 100644 index 04a1ed27..00000000 --- a/firebase/assessment/firestore.indexes.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "indexes": [ - { - "collectionGroup": "variants", - "queryScope": "COLLECTION", - "fields": [ - { - "fieldPath": "blocksString", - "order": "ASCENDING" - }, - { - "fieldPath": "name", - "order": "ASCENDING" - }, - { - "fieldPath": "lastPlayed", - "order": "DESCENDING" - } - ] - } - ], - "fieldOverrides": [] -} diff --git a/firebase/assessment/firestore.rules b/firebase/assessment/firestore.rules deleted file mode 100644 index 3035ebaf..00000000 --- a/firebase/assessment/firestore.rules +++ /dev/null @@ -1,419 +0,0 @@ -rules_version = '2'; -service cloud.firestore { - match /databases/{database}/documents { - // Allow super_admins to do everything - match /{everythingInMyDatabase=**} { - allow read, write: if request.auth.token.get("super_admin", false) == true; - } - - function loggedIn() { - return request.auth != null; - } - - function roarUid() { - return request.auth.token.get("roarUid", ""); - } - - // The auth token has a custom claim for the organizations that the user is - // an admin for. The expected data structure is - // token.adminOrgs = { - // districts?: string[], - // schools?: string[], - // classes?: string[], - // families?: string[], - // groups?: string[], - // } - function getAdminList(orgType) { - return request.auth.token.get("adminOrgs", {}).get(orgType, []).toSet(); - } - - function targetOrgsInAdminList(orgType, targetOrgIds) { - return targetOrgIds.size() > 0 && getAdminList(orgType).hasAny(targetOrgIds); - } - - function keysNotUpdated(keys) { - return !request.resource.data.diff(resource.data).affectedKeys().hasAny(keys); - } - - function onlyTheseKeysUpdated(keys) { - return request.resource.data.diff(resource.data).affectedKeys().hasOnly(keys); - } - - // We use the userClaims collection to store refresh timestamps to propogate - // custom user claims back to the client. - match /userClaims/{uid} { - allow read: if loggedIn() && uid == request.auth.uid; - allow write: if false; // Only write in cloud functions using admin SDK - } - - match /users/{uid} { - function myData() { - return loggedIn() && uid == roarUid(); - } - - function isCurrentOrPreviousAdmin() { - return (targetOrgsInAdminList('districts', resource.data.get(['districts', 'all'], [])) - || targetOrgsInAdminList('schools', resource.data.get(['schools', 'all'], [])) - || targetOrgsInAdminList('classes', resource.data.get(['classes', 'all'], [])) - || targetOrgsInAdminList('families', resource.data.get(['families', 'all'], [])) - || targetOrgsInAdminList('groups', resource.data.get(['groups', 'all'], []))); - } - - function canReadExistingUser() { - return loggedIn() && (myData() || isCurrentOrPreviousAdmin()); - } - - allow read: if canReadExistingUser(); - - // We now address creating a new user: - // If the authenticated user (requestor) is creating a new user (target), then - // the requestor must satisfy one of the following conditions - // - the requestor is a district admin for the districtId of the target. The schoolId and classId must be in that district. And families and groups must be empty. - // - the requestor is a school admin for the schoolId of the target. The districtId of the target must match that of the school. And the classId must be in the school. - // - the requestor is a class admin for the classId of the target. The districtId and schoolId of the target must match that of the class. - // - the requestor is a family admin and the target is in the same family. No educational orgs can be set - // - the requestor is a group admin for the groupId of the target. No educational orgs can be set - - function commonKeys() { - return ['userType', 'birthMonth', 'birthYear', 'createdAt', 'assessmentPid', 'lastUpdated']; - } - - function educationalOrgKeys() { - return ['classes', 'schools', 'districts']; - } - - function readOnlyKeys() { - return ['assessmentUid'] - } - - function userUpdateKeys() { - return [ - 'lastUpdated', - 'tasks', - 'variants', - ] - } - - function allowedEduKeys(newUser) { - let allowedKeys = commonKeys().concat(educationalOrgKeys()); - return newUser ? allowedKeys.concat(['assessmentUid']) : allowedKeys; - } - - function allowedFamilyKeys(newUser) { - let allowedKeys = commonKeys().concat(['families']); - return newUser ? allowedKeys.concat(['assessmentUid']) : allowedKeys; - } - - function allowedGroupKeys(newUser) { - let allowedKeys = commonKeys().concat(['groups']); - return newUser ? allowedKeys.concat(['assessmentUid']) : allowedKeys; - } - - function requestHasOnlyEduKeys(newUser) { - return request.resource.data.keys().hasOnly(allowedEduKeys(newUser)); - } - - function requestHasOnlyGroupKeys(newUser) { - return request.resource.data.keys().hasAny(allowedGroupKeys(newUser)); - } - - function requestHasOnlyFamilyKeys(newUser) { - return request.resource.data.keys().hasAny(allowedFamilyKeys(newUser)); - } - - function noPreviousOrgsOfThisType(orgType) { - let orgData = request.resource.data.get(orgType, {}); - let currentIds = orgData.get('current', []); - return orgData.keys().size() > 0 && currentIds.size() > 0 && orgData.get('all', []).hasOnly(currentIds) && orgData.get('dates', {}).keys().hasOnly(currentIds); - } - - function requestHasOnlyCurrentOrgs() { - return noPreviousOrgsOfThisType('districts') - && noPreviousOrgsOfThisType('schools') - && noPreviousOrgsOfThisType('classes') - && noPreviousOrgsOfThisType('groups') - && noPreviousOrgsOfThisType('families'); - } - - function atMostOneOrgInRequest(orgType) { - return request.resource.data.get([orgType, 'current'], []).size() <= 1; - } - - function atMostOneDistrictAndSchoolInRequest() { - return atMostOneOrgInRequest('districts') && atMostOneOrgInRequest('schools'); - } - - function getOrgDoc(orgType, orgId) { - return get(/databases/$(database)/documents/$(orgType)/$(orgId)).data - } - - function orgHasMatchingKey(orgType, orgId, key, valueToMatch) { - return getOrgDoc(orgType, orgId).get(key, 'nullId') == valueToMatch; - } - - function requestOrgsInAdminList(orgType) { - return targetOrgsInAdminList(orgType, request.resource.data.get([orgType, 'current'], [])); - } - - function isClassAdminForNewUser() { - let data = request.resource.data; - let currentDistrict = data.get(['districts', 'current'], ['nullId'])[0]; - let currentSchool = data.get(['schools', 'current'], ['nullId'])[0]; - let currentClass = data.get(['classes', 'current'], ['nullId'])[0]; - let currentClassDoc = getOrgDoc('classes', currentClass); - - return (requestOrgsInAdminList('classes') - && atMostOneOrgInRequest('classes') - && currentClassDoc.get('districtId', 'nullId') == currentDistrict - && currentClassDoc.get('schoolId', 'nullId') == currentSchool); - } - - function isSchoolAdminForNewUser() { - let data = request.resource.data; - let currentDistrict = data.get(['districts', 'current'], ['nullId'])[0]; - let currentSchool = data.get(['schools', 'current'], ['nullId'])[0]; - let currentClasses = data.get(['classes', 'current'], []); - let currentSchoolDoc = getOrgDoc('schools', currentSchool); - - return (requestOrgsInAdminList('schools') - && orgHasMatchingKey('schools', currentSchool, 'districtId', currentDistrict)) - && currentClasses.hasOnly(currentSchoolDoc.get('classes', [])) - } - - function isDistrictAdminForNewUser() { - // TODO: Make sure all of the classes are also in the district - // In order to accomplish this, I think we may need to record class IDs in the district doc - let data = request.resource.data; - let currentDistrict = data.get(['districts', 'current'], ['nullId'])[0]; - let currentSchools = data.get(['schools', 'current'], []); - let currentDistrictDoc = getOrgDoc('districts', currentDistrict); - - return (requestOrgsInAdminList('districts') - && currentSchools.hasOnly(currentDistrictDoc.get('schools', []))) - } - - function isEduAdminForNewUser() { - // Add 'assessmentUid' to the list of allowed keys only for user doc creation - return requestHasOnlyEduKeys(true) && (isDistrictAdminForNewUser() || isSchoolAdminForNewUser() || isClassAdminForNewUser()); - } - - function isAdminForNewUser() { - let familyAdminCondition = (requestOrgsInAdminList('families') && requestHasOnlyFamilyKeys(true)); - let groupAdminCondition = (requestOrgsInAdminList('groups') && requestHasOnlyGroupKeys(true)); - - return (familyAdminCondition || groupAdminCondition || isEduAdminForNewUser()); - } - - function canCreateUser() { - return loggedIn() && isAdminForNewUser() && requestHasOnlyCurrentOrgs() && atMostOneDistrictAndSchoolInRequest(); - } - - allow create: if canCreateUser(); - - // We now address updating an existing user - // If the authenticated user (requestor) is updating an existing user (target), then - // the requestor must satisfy one of the following conditions - // - the requestor is a district admin for the districtId of the target. They can only update common + educational keys. Any added schools and classes must be in the district. - // - the requestor is a school admin for the schoolId of the target. They may not update district info. They can only update common + educational keys. Any added classes must be in the school. - // - the requestor is a class admin for the classId of the target. The districtId and schoolId of the target must match that of the class. - // - the requestor is a family admin for a current family of the target. No educational orgs can be set - // - the requestor is a group admin for a current group of the target. No educational orgs can be set - - function resourceOrgsInAdminList(orgType) { - return targetOrgsInAdminList(orgType, resource.data.get([orgType, 'current'], [])); - } - - function orgTypeIsConsistent(orgType) { - let data = request.resource.data; - return data.get([orgType, 'all'], []).hasAll(data.get([orgType, 'current'], [])); - } - - function orgsAreConsistent() { - return orgTypeIsConsistent('districts') - && orgTypeIsConsistent('schools') - && orgTypeIsConsistent('classes') - && orgTypeIsConsistent('families') - && orgTypeIsConsistent('groups'); - } - - function isDistrictAdmin() { - return resourceOrgsInAdminList('districts') && keysNotUpdated(['districts']); - } - - function isSchoolAdmin() { - return resourceOrgsInAdminList('schools') && keysNotUpdated(['districts', 'schools']); - } - - function isClassAdmin() { - return resourceOrgsInAdminList('classes') && keysNotUpdated(['districts', 'schools', 'classes']); - } - - function isEduAdmin() { - return requestHasOnlyEduKeys(false) - && atMostOneDistrictAndSchoolInRequest() - && keysNotUpdated(['name']) - && (isDistrictAdmin() || isSchoolAdmin() || isClassAdmin()) - } - - function isFamilyAdmin() { - return resourceOrgsInAdminList('families') && requestHasOnlyFamilyKeys(false) - } - - function isGroupAdmin() { - return resourceOrgsInAdminList('groups') && requestHasOnlyGroupKeys(false) - } - - function isCurrentAdmin() { - return keysNotUpdated(readOnlyKeys().concat(userUpdateKeys())) && (isEduAdmin() || isFamilyAdmin() || isGroupAdmin()) - } - - function editingMyData() { - return myData() && onlyTheseKeysUpdated(userUpdateKeys()); - } - - allow update: if (isCurrentAdmin() || editingMyData()) && orgsAreConsistent(); - - // Explicitly define rules for the "runs" subcollection - // N.B. This assumes that the assigningOrgs are exhaustively listed. E.g., if district 1 assigns an administration, - // then schools A and B, which are in district 1, are assumed to also be listed in assigningOrgs. - match /runs/{runId} { - function isAdminForAssigningOrg(orgType) { - return targetOrgsInAdminList(orgType, resource.data.get(['assigningOrgs', orgType], [])); - } - - function isAdminForAnyAssigningOrg() { - return (isAdminForAssigningOrg('districts') - || isAdminForAssigningOrg('schools') - || isAdminForAssigningOrg('classes') - || isAdminForAssigningOrg('groups') - || isAdminForAssigningOrg('families')); - } - - function canReadRun() { - return loggedIn() && (myData() || isAdminForAnyAssigningOrg()); - } - - function canCreateRun() { - return myData(); - } - - function canUpdateRun() { - return myData() && keysNotUpdated(['assigningOrgs']); - } - - allow read: if canReadRun(); - allow create: if canCreateRun(); - allow update: if canUpdateRun(); - - // N.B. This assumes that the assigningOrgs are exhaustively listed. E.g., if district 1 assigns an administration, - // then schools A and B, which are in district 1, are assumed to also be listed in assigningOrgs. - match /trials/{trialId} { - // These are versions of the above rules with database reads. This is - // needed because resource.data for trial documents will not contain - // `assigningOrgs`. So we must read the parent document first. - function isAdminForAnyAssigningOrgWithDbRead() { - let assigningOrgs = get(/databases/$(database)/documents/users/$(uid)/runs/$(runId)).data.get('assigningOrgs', []); - return (targetOrgsInAdminList('districts', assigningOrgs.get('districts', [])) - || targetOrgsInAdminList('schools', assigningOrgs.get('schools', [])) - || targetOrgsInAdminList('classes', assigningOrgs.get('classes', [])) - || targetOrgsInAdminList('groups', assigningOrgs.get('groups', [])) - || targetOrgsInAdminList('families', assigningOrgs.get('families', []))); - } - - function canReadTrial() { - return loggedIn() && (myData() || isAdminForAnyAssigningOrgWithDbRead()); - } - - function canWriteTrial() { - return myData(); - } - - allow read: if canReadTrial(); - allow write: if canWriteTrial(); - } - } - } - - // We allow anonymous guest access so that people can try an individual ROAR - // app without having to create a dashboard account. - // There are a few use cases for this: - // - Users may want to try out ROAR before creating an account - // - App developers may want to pilot new apps or features without deploying to the dashboard - // - External lab partners may want to use ROAR apps without the dashboard. In this case, they - // use the individually hosted app and have users submit their own internal lab identifiers. - // It is up to them to keep track of these identifiers in their own user management system. - // - // We restrict all read access to guest documents. Only super_admins and the admin SDK can read them. - // We will rely on cloud functions using the admin SDK to export this data to the labs. - match /guests/{guestUid} { - function guestData() { - return loggedIn() && request.auth.uid == guestUid; - } - - function canWriteGuests() { - return guestData(); - } - - allow read: if guestData(); - allow create: if canWriteGuests() && request.resource.data.get('userType', 'nullType') == 'guest'; - allow update: if canWriteGuests() && keysNotUpdated(['userType']); - allow delete: if false; - - match /runs/{runId} { - allow read: if false; - allow create: if canWriteGuests(); - allow update: if canWriteGuests(); - allow delete: if false; - - match /trials/{trialId} { - allow read: if false; - allow create: if canWriteGuests(); - allow update: if canWriteGuests(); - allow delete: if false; - } - } - } - - // N.B.: This is a placeholder for tracking the provenance of corpora. We - // don't currently use it so we lock down access to it. - match /corpora/{corpusId}/{document=**} { - allow read: if false; - allow write: if false; - } - - // Tasks and variants are free for any authenticated user to read and create. - // Updates are only allowed for certain fields. - // Only super_admins can update the `registered` field. - match /tasks/{taskId} { - function isUpdateToOnly(allowedFields) { - return request.resource.data.diff(resource.data).affectedKeys().hasOnly(allowedFields); - } - - function canCreateTask() { - return loggedIn() && !request.resource.data.keys().hasAny(['registered']) - } - - function canUpdateTask() { - let newParams = request.resource.data.get("params", {}); - let oldParams = resource.data.get("params", {}); - let noAddedParams = newParams.diff(oldParams).addedKeys().size() == 0; - let noDeletedParams = newParams.diff(oldParams).removedKeys().size() == 0; - return (loggedIn() - && keysNotUpdated(['registered']) - && isUpdateToOnly(['description', 'lastUpdated', 'params']) - && noAddedParams - && noDeletedParams); - } - - allow read: if loggedIn(); - allow create: if canCreateTask(); - allow update: if canUpdateTask(); - - match /variants/{variantId} { - allow read: if loggedIn(); - allow create: if canCreateTask(); - allow update: if canUpdateTask(); - } - } - } -}