Repository: nasa/openmct (language: javascript)

Issue:
Support locking objects en-masse to prevent ad-hoc changes in restricted environments.
**Is your feature request related to a problem? Please describe.**

Certain of our operational environments require a subset of domain objects to be immutable to regular users. For example, displays that users are trained on should only be modified following a change-management process, and should not be modifiable ad-hoc during live operations.

**Describe the solution you'd like**
Open MCT already supports object locking. This capability could be leveraged as a mechanism for preventing changes in operational environments by enforcing object locking on the server-side (ie. in Couch DB), and disallowing users from unlocking them.

Implementation is in a single PR, but I've split this task into multiple GitHub issues to allow us to split up testing more effectively.

* [x] #7880
* [x] #7878
* [x] #7879

In all cases the existing user-locking functionality should be retained.

**Describe alternatives you've considered**
A second read-only couch database. This requires more widespread changes to Open MCT and the attendant risk associated with large-scale changes to our persistence API.


Discussion / hints:

Fix patch:
diff --git a/package-lock.json b/package-lock.json
index dc6193af5f0..c82e60d1a72 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -66,6 +66,7 @@
         "moment": "2.30.1",
         "moment-duration-format": "2.3.2",
         "moment-timezone": "0.5.41",
+        "nano": "10.1.4",
         "npm-run-all2": "6.1.2",
         "nyc": "15.1.0",
         "painterro": "1.2.87",
@@ -2463,6 +2464,12 @@
         "@mdn/browser-compat-data": "^5.2.34"
       }
     },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "dev": true
+    },
     "node_modules/axe-core": {
       "version": "4.8.4",
       "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.8.4.tgz",
@@ -2472,6 +2479,17 @@
         "node": ">=4"
       }
     },
+    "node_modules/axios": {
+      "version": "1.7.7",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
+      "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
+      "dev": true,
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
     "node_modules/babel-loader": {
       "version": "9.1.0",
       "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.0.tgz",
@@ -3012,6 +3030,18 @@
         "node": ">=0.1.90"
       }
     },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/comma-separated-values": {
       "version": "3.6.4",
       "resolved": "https://registry.npmjs.org/comma-separated-values/-/comma-separated-values-3.6.4.tgz",
@@ -4102,6 +4132,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
     "node_modules/depd": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -5755,6 +5794,20 @@
         "node": ">=8.0.0"
       }
     },
+    "node_modules/form-data": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+      "dev": true,
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -7896,6 +7949,35 @@
         "multicast-dns": "cli.js"
       }
     },
+    "node_modules/nano": {
+      "version": "10.1.4",
+      "resolved": "https://registry.npmjs.org/nano/-/nano-10.1.4.tgz",
+      "integrity": "sha512-bJOFIPLExIbF6mljnfExXX9Cub4W0puhDjVMp+qV40xl/DBvgKao7St4+6/GB6EoHZap7eFnrnx4mnp5KYgwJA==",
+      "dev": true,
+      "dependencies": {
+        "axios": "^1.7.4",
+        "node-abort-controller": "^3.1.1",
+        "qs": "^6.13.0"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/nano/node_modules/qs": {
+      "version": "6.13.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
+      "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+      "dev": true,
+      "dependencies": {
+        "side-channel": "^1.0.6"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/nanoid": {
       "version": "3.3.7",
       "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@@ -7935,6 +8017,12 @@
       "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
       "dev": true
     },
+    "node_modules/node-abort-controller": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
+      "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
+      "dev": true
+    },
     "node_modules/node-fetch": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -9185,6 +9273,12 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "dev": true
+    },
     "node_modules/pump": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
diff --git a/package.json b/package.json
index 35863a7efc0..b52c369f190 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
     "moment": "2.30.1",
     "moment-duration-format": "2.3.2",
     "moment-timezone": "0.5.41",
+    "nano": "10.1.4",
     "npm-run-all2": "6.1.2",
     "nyc": "15.1.0",
     "painterro": "1.2.87",
@@ -156,4 +157,4 @@
   "keywords": [
     "nasa"
   ]
-}
\ No newline at end of file
+}
diff --git a/src/plugins/duplicate/DuplicateAction.js b/src/plugins/duplicate/DuplicateAction.js
index 0f26dabe63b..6e905f42776 100644
--- a/src/plugins/duplicate/DuplicateAction.js
+++ b/src/plugins/duplicate/DuplicateAction.js
@@ -109,8 +109,9 @@ class DuplicateAction {
       let currentParentKeystring = this.openmct.objects.makeKeyString(currentParent.identifier);
       let parentCandidateKeystring = this.openmct.objects.makeKeyString(parentCandidate.identifier);
       let objectKeystring = this.openmct.objects.makeKeyString(this.object.identifier);
+      const isLocked = parentCandidate.locked === true;
 
-      if (!this.openmct.objects.isPersistable(parentCandidate.identifier)) {
+      if (isLocked || !this.openmct.objects.isPersistable(parentCandidate.identifier)) {
         return false;
       }
 
@@ -139,10 +140,9 @@ class DuplicateAction {
     const parentType = parent && this.openmct.types.get(parent.type);
     const child = objectPath[0];
     const childType = child && this.openmct.types.get(child.type);
-    const locked = child.locked ? child.locked : parent && parent.locked;
     const isPersistable = this.openmct.objects.isPersistable(child.identifier);
 
-    if (locked || !isPersistable) {
+    if (!isPersistable) {
       return false;
     }
 
diff --git a/src/plugins/formActions/EditPropertiesAction.js b/src/plugins/formActions/EditPropertiesAction.js
index 713e800c768..dc3d3ee0bd4 100644
--- a/src/plugins/formActions/EditPropertiesAction.js
+++ b/src/plugins/formActions/EditPropertiesAction.js
@@ -45,7 +45,7 @@ class EditPropertiesAction extends PropertiesAction {
     const definition = this._getTypeDefinition(object.type);
     const persistable = this.openmct.objects.isPersistable(object.identifier);
 
-    return persistable && definition && definition.creatable;
+    return persistable && definition && definition.creatable && !object.locked;
   }
 
   invoke(objectPath) {
diff --git a/src/plugins/inspectorViews/properties/PropertiesComponent.vue b/src/plugins/inspectorViews/properties/PropertiesComponent.vue
index 77e3c837e78..8773fd82a61 100644
--- a/src/plugins/inspectorViews/properties/PropertiesComponent.vue
+++ b/src/plugins/inspectorViews/properties/PropertiesComponent.vue
@@ -96,6 +96,8 @@ export default {
       const createdTimestamp = this.domainObject.created;
       const createdBy = this.domainObject.createdBy ? this.domainObject.createdBy : UNKNOWN_USER;
       const modifiedBy = this.domainObject.modifiedBy ? this.domainObject.modifiedBy : UNKNOWN_USER;
+      const locked = this.domainObject.locked;
+      const lockedBy = this.domainObject.lockedBy ?? UNKNOWN_USER;
       const modifiedTimestamp = this.domainObject.modified
         ? this.domainObject.modified
         : this.domainObject.created;
@@ -148,6 +150,13 @@ export default {
         });
       }
 
+      if (locked === true) {
+        details.push({
+          name: 'Locked By',
+          value: lockedBy
+        });
+      }
+
       if (version) {
         details.push({
           name: 'Version',
diff --git a/src/plugins/persistence/couch/scripts/lockObjects.mjs b/src/plugins/persistence/couch/scripts/lockObjects.mjs
new file mode 100644
index 00000000000..f7edf69459d
--- /dev/null
+++ b/src/plugins/persistence/couch/scripts/lockObjects.mjs
@@ -0,0 +1,191 @@
+import http from 'http';
+import nano from 'nano';
+import { parseArgs } from 'util';
+
+const COUCH_URL = process.env.OPENMCT_COUCH_URL || 'http://127.0.0.1:5984';
+const COUCH_DB_NAME = process.env.OPENMCT_DATABASE_NAME || 'openmct';
+
+const {
+  values: { couchUrl, database, lock, unlock, startObjectKeystring, user, pass }
+} = parseArgs({
+  options: {
+    couchUrl: {
+      type: 'string',
+      default: COUCH_URL
+    },
+    database: {
+      type: 'string',
+      short: 'd',
+      default: COUCH_DB_NAME
+    },
+    lock: {
+      type: 'boolean',
+      short: 'l'
+    },
+    unlock: {
+      type: 'boolean',
+      short: 'u'
+    },
+    startObjectKeystring: {
+      type: 'string',
+      short: 'o',
+      default: 'mine'
+    },
+    user: {
+      type: 'string'
+    },
+    pass: {
+      type: 'string'
+    }
+  }
+});
+
+const BATCH_SIZE = 100;
+const SOCKET_POOL_SIZE = 100;
+
+const locked = lock === true;
+console.info(`Connecting to ${couchUrl}/${database}`);
+console.info(`${locked ? 'Locking' : 'Unlocking'} all children of ${startObjectKeystring}`);
+
+const poolingAgent = new http.Agent({
+  keepAlive: true,
+  maxSockets: SOCKET_POOL_SIZE
+});
+
+const db = nano({
+  url: couchUrl,
+  requestDefaults: {
+    agent: poolingAgent
+  }
+}).use(database);
+db.auth(user, pass);
+
+if (!unlock && !lock) {
+  throw new Error('Either -l or -u option is required');
+}
+
+const startObjectIdentifier = keystringToIdentifier(startObjectKeystring);
+const documentBatch = [];
+const alreadySeen = new Set();
+let updatedDocumentCount = 0;
+
+await processObjectTreeFrom(startObjectIdentifier);
+//Persist final batch
+await persistBatch();
+console.log(`Processed ${updatedDocumentCount} documents`);
+
+function processObjectTreeFrom(parentObjectIdentifier) {
+  //1. Fetch document for identifier;
+  return fetchDocument(parentObjectIdentifier)
+    .then(async (document) => {
+      if (document !== undefined) {
+        if (!alreadySeen.has(document._id)) {
+          alreadySeen.add(document._id);
+          //2. Lock or unlock object
+          document.model.locked = locked;
+          document.model.disallowUnlock = locked;
+
+          if (locked) {
+            document.model.lockedBy = 'script';
+          } else {
+            delete document.model.lockedBy;
+          }
+          //3. Push document to a batch
+          documentBatch.push(document);
+          //4. Persist batch if necessary, reporting failures
+          await persistBatchIfNeeded();
+          //5. Repeat for each composee
+          const composition = document.model.composition || [];
+          return Promise.all(
+            composition.map((composee) => {
+              return processObjectTreeFrom(composee);
+            })
+          );
+        }
+      }
+    })
+    .catch((error) => {
+      console.log(`Error ${error}`);
+    });
+}
+
+async function fetchDocument(identifierOrKeystring) {
+  let keystring;
+  if (typeof identifierOrKeystring === 'object') {
+    keystring = identifierToKeystring(identifierOrKeystring);
+  } else {
+    keystring = identifierOrKeystring;
+  }
+
+  try {
+    const document = await db.get(keystring);
+
+    return document;
+  } catch (error) {
+    return undefined;
+  }
+}
+
+function persistBatchIfNeeded() {
+  if (documentBatch.length >= BATCH_SIZE) {
+    return persistBatch();
+  } else {
+    //Noop - batch is not big enough yet
+    return;
+  }
+}
+
+async function persistBatch() {
+  try {
+    const localBatch = [].concat(documentBatch);
+
+    //Immediately clear the shared batch array. This asynchronous process is non-blocking, and
+    //we don't want to try and persist the same batch multiple times while we are waiting for
+    //the subsequent bulk operation to complete.
+    updatedDocumentCount += documentBatch.length;
+
+    documentBatch.splice(0, documentBatch.length);
+    const response = await db.bulk({ docs: localBatch });
+
+    if (response instanceof Array) {
+      response.forEach((r) => {
+        console.info(JSON.stringify(r));
+      });
+    } else {
+      console.info(JSON.stringify(response));
+    }
+  } catch (error) {
+    if (error instanceof Array) {
+      error.forEach((e) => console.error(JSON.stringify(e)));
+    } else {
+      console.error(`${error.statusCode} - ${error.reason}`);
+    }
+  }
+}
+
+function keystringToIdentifier(keystring) {
+  const tokens = keystring.split(':');
+  if (tokens.length === 2) {
+    return {
+      namespace: tokens[0],
+      key: tokens[1]
+    };
+  } else {
+    return {
+      namespace: '',
+      key: tokens[0]
+    };
+  }
+}
+
+function identifierToKeystring(identifier) {
+  if (typeof identifier === 'string') {
+    return identifier;
+  } else if (typeof identifier === 'object') {
+    if (identifier.namespace) {
+      return `${identifier.namespace}:${identifier.key}`;
+    } else {
+      return identifier.key;
+    }
+  }
+}
diff --git a/src/plugins/persistence/couch/setup-couchdb.sh b/src/plugins/persistence/couch/setup-couchdb.sh
index 18d81768830..88ae9f5323a 100755
--- a/src/plugins/persistence/couch/setup-couchdb.sh
+++ b/src/plugins/persistence/couch/setup-couchdb.sh
@@ -160,6 +160,24 @@ add_index_and_views() {
         echo "Unable to create annotation_keystring_index"
         echo $response
     fi
+
+    # Add auth database for locked objects
+    response=$(curl --silent --user "${CURL_USERPASS_ARG}" --request PUT "$COUCH_BASE_LOCAL"/"$OPENMCT_DATABASE_NAME"/_design/auth \
+    --header 'Content-Type: application/json' \
+    --data '{
+      "_id": "_design/auth",
+      "language": "javascript",
+      "validate_doc_update": "function (newDoc, oldDoc, userCtx) {  if (userCtx.roles.indexOf('\''_admin'\'') !== -1) {    return;  } else if (oldDoc === null) {    return;  } else if (oldDoc.model.type === '\''timer'\'' || oldDoc.model.type === '\''notebook'\'' || oldDoc.model.type === '\''restricted-notebook'\'') {    if (oldDoc.model.name !== newDoc.model.name) {      throw ({        forbidden: '\''Read-only object'\''      });    } else {      return;    }  } else if (oldDoc.model.locked === true && oldDoc.model.disallowUnlock === true) {    throw ({      forbidden: '\''Read-only object'\''    });  } else {    return;  }}"
+    }')
+
+    if [[ $response =~ "\"ok\":true" ]]; then
+        echo "Successfully created _design/auth design document for locked objects"
+    elif [[ $response =~ "\"error\":\"conflict\"" ]]; then
+        echo "_design/auth already exists, skipping creation"
+    else
+        echo "Unable to create _design/auth"
+        echo $response
+    fi
 }
 
 # Main script execution
diff --git a/src/ui/layout/BrowseBar.vue b/src/ui/layout/BrowseBar.vue
index 013682a0f77..f9ea2147325 100644
--- a/src/ui/layout/BrowseBar.vue
+++ b/src/ui/layout/BrowseBar.vue
@@ -36,7 +36,7 @@
           ref="objectName"
           class="l-browse-bar__object-name c-object-label__name"
           :class="{ 'c-input-inline': isPersistable }"
-          :contenteditable="isPersistable"
+          :contenteditable="isNameEditable"
           @blur="updateName"
           @keydown.enter.prevent
           @keyup.enter.prevent="updateNameOnEnterKeyPress"
@@ -78,7 +78,7 @@
         ></button>
 
         <button
-          v-if="isViewEditable & !isEditing"
+          v-if="shouldShowLock"
           :aria-label="lockedOrUnlockedTitle"
           :title="lockedOrUnlockedTitle"
           :class="{
@@ -88,6 +88,13 @@
           @click="toggleLock(!domainObject.locked)"
         ></button>
 
+        <span
+          v-else-if="domainObject?.locked"
+          class="icon-lock"
+          aria-label="Locked for editing, cannot be unlocked."
+          title="Locked for editing, cannot be unlocked."
+        ></span>
+
         <button
           v-if="isViewEditable && !isEditing && !domainObject.locked"
           class="l-browse-bar__actions__edit c-button c-button--major icon-pencil"
@@ -180,6 +187,18 @@ export default {
     };
   },
   computed: {
+    isNameEditable() {
+      return this.isPersistable && !this.domainObject.locked;
+    },
+    shouldShowLock() {
+      if (this.domainObject === undefined) {
+        return false;
+      }
+      if (this.domainObject.disallowUnlock) {
+        return false;
+      }
+      return this.domainObject.locked || (this.isViewEditable && !this.isEditing);
+    },
     statusClass() {
       return this.status ? `is-status--${this.status}` : '';
     },
@@ -253,11 +272,19 @@ export default {
       return false;
     },
     lockedOrUnlockedTitle() {
+      let title;
       if (this.domainObject.locked) {
-        return 'Locked for editing - click to unlock.';
+        if (this.domainObject.lockedBy !== undefined) {
+          title = `Locked for editing by ${this.domainObject.lockedBy}. `;
+        } else {
+          title = 'Locked for editing. ';
+        }
+        title += 'Click to unlock.';
       } else {
-        return 'Unlocked for editing - click to lock.';
+        title = 'Unlocked for editing, click to lock.';
       }
+
+      return title;
     },
     domainObjectName() {
       return this.domainObject?.name ?? '';
@@ -288,7 +315,6 @@ export default {
     document.addEventListener('click', this.closeViewAndSaveMenu);
     this.promptUserbeforeNavigatingAway = this.promptUserbeforeNavigatingAway.bind(this);
     window.addEventListener('beforeunload', this.promptUserbeforeNavigatingAway);
-
     this.openmct.editor.on('isEditing', (isEditing) => {
       this.isEditing = isEditing;
     });
@@ -421,8 +447,27 @@ export default {
       this.actionCollection.off('update', this.updateActionItems);
       delete this.actionCollection;
     },
-    toggleLock(flag) {
-      this.openmct.objects.mutate(this.domainObject, 'locked', flag);
+    async toggleLock(flag) {
+      if (!this.domainObject.disallowUnlock) {
+        const wasTransactionActive = this.openmct.objects.isTransactionActive();
+        let transaction;
+
+        if (!wasTransactionActive) {
+          transaction = this.openmct.objects.startTransaction();
+        }
+
+        this.openmct.objects.mutate(this.domainObject, 'locked', flag);
+        const user = await this.openmct.user.getCurrentUser();
+
+        if (user !== undefined) {
+          this.openmct.objects.mutate(this.domainObject, 'lockedBy', user.id);
+        }
+
+        if (!wasTransactionActive) {
+          await transaction.commit();
+          this.openmct.objects.endTransaction();
+        }
+      }
     },
     setStatus(status) {
       this.status = status;


Test patch: