Bug 1778959 - SitePermsAddonProvider basic boilerplate skeleton. r=rpl,willdurand.
authornchevobbe <nchevobbe@mozilla.com>
Fri, 30 Sep 2022 22:07:17 +0000
changeset 636745 1a68797d8bbc5b12fef27c8e9d48b0aeb9e8baa8
parent 636744 810b2ee0bf5fb84e6b651a08278e48c1cd092204
child 636746 58083286fe3d3d1e55ddca60813ca15456eae633
push id170482
push usernchevobbe@mozilla.com
push dateFri, 30 Sep 2022 22:10:01 +0000
treeherderautoland@1ef96ac13291 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrpl, willdurand
bugs1778959
milestone107.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1778959 - SitePermsAddonProvider basic boilerplate skeleton. r=rpl,willdurand. This patch includes a basic version of a new SitePermsAddonProvider. An xpcshell test ensures that the provider works as expected. Differential Revision: https://phabricator.services.mozilla.com/D151476
toolkit/components/telemetry/Events.yaml
toolkit/mozapps/extensions/extensions.manifest
toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs
toolkit/mozapps/extensions/internal/XPIInstall.jsm
toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs
toolkit/mozapps/extensions/internal/moz.build
toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs
toolkit/mozapps/extensions/test/xpcshell/head_addons.js
toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -193,26 +193,27 @@ addonsManager:
     extra_keys:
       addon_id: A string which identify the extension (when available)
       download_time: The number of ms needed to complete the download
       error: The AddonManager error related to an install or update failure.
       source: >
         The source that originally triggered the add-on installation, one of "about:addons",
         "about:debugging", "about:preferences", "amo", "disco", "distribution",
         "extension", "enterprise-policy", "file-url", "geckoview-app", "gmp-plugin",
-        "internal", "plugin", "rtamo", "sync", "system-addon", "temporary-addon", "unknown".
+        "internal", "plugin", "rtamo", "siteperm-addon-provider" "sync", "system-addon",
+        "temporary-addon", "unknown".
         For events with method set to "sideload", the source value is derived from the XPIProvider
         location name (e.g. possible values are "app-builtin", "app-global", "app-profile",
         "app-system-addons", "app-system-defaults", "app-system-local", "app-system-profile",
         "app-system-share", "app-system-user", "winreg-app-user", "winreg-app-gobal")
       method: >
         The method used by the source to install the add-on (included when the source can use more than one,
         e.g. install events with source "about:addons" may have "install-from-file" or "url" as method),
         one of "amWebAPI", "drag-and-drop", "installTrigger", "install-from-file", "link",
-        "management-webext-api", "sideload", "url", "product-updates".
+        "management-webext-api", "sideload", "synthetic-install", "url", "product-updates".
       num_strings: The number of permission description strings in the extension permission doorhanger
       updated_from: Determine if an update has been requested by the user or the application ("app" / "user")
       install_origins: This flag indicates whether install_origins is defined in the addon manifest. ("1" / "0")
       step: >
         The current step in the install or update flow:
           - started, postponed, cancelled, failed, permissions_prompt, completed
           - site_warning, site_blocked, install_disabled_warning
           - download_started, download_completed, download_failed
--- a/toolkit/mozapps/extensions/extensions.manifest
+++ b/toolkit/mozapps/extensions/extensions.manifest
@@ -1,8 +1,9 @@
 #ifndef MOZ_WIDGET_ANDROID
 category update-timer addonManager @mozilla.org/addons/integration;1,getService,addon-background-update-timer,extensions.update.interval,86400
 #endif
 #ifndef MOZ_THUNDERBIRD
 #ifndef MOZ_WIDGET_ANDROID
 category addon-provider-module GMPProvider resource://gre/modules/addons/GMPProvider.sys.mjs
+category addon-provider-module SitePermsAddonProvider resource://gre/modules/addons/SitePermsAddonProvider.sys.mjs
 #endif
 #endif
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs
@@ -0,0 +1,346 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { computeSha256HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs";
+import {
+  GATED_PERMISSIONS,
+  SITEPERMS_ADDON_PROVIDER_PREF,
+  SITEPERMS_ADDON_TYPE,
+  isGatedPermissionType,
+  isKnownPublicSuffix,
+} from "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+  AddonManager: "resource://gre/modules/AddonManager.jsm",
+  AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
+});
+
+const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created";
+const SITEPERMS_ADDON_ID_SUFFIX = "@siteperms.mozilla.org";
+
+class SitePermsAddonWrapper {
+  // A <string, nsIPermission> Map, whose keys are permission types.
+  #permissionsByPermissionType = new Map();
+
+  /**
+   * @param {string} siteOrigin: The origin this addon is installed for
+   * @param {Array<nsIPermission>} permissions: An array of the initial permissions the user
+   *                               granted for the addon origin.
+   */
+  constructor(siteOrigin, permissions = []) {
+    this.siteOrigin = siteOrigin;
+    this.principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+      this.siteOrigin
+    );
+    this.id = `${computeSha256HashAsString(
+      this.siteOrigin
+    )}${SITEPERMS_ADDON_ID_SUFFIX}`;
+
+    for (const perm of permissions) {
+      this.#permissionsByPermissionType.set(perm.type, perm);
+    }
+  }
+
+  /**
+   * Returns the list of gated permissions types granted for the instance's origin
+   *
+   * @return {Array<String>}
+   */
+  get sitePermissions() {
+    return Array.from(this.#permissionsByPermissionType.keys());
+  }
+
+  /**
+   * Update #permissionsByPermissionType, and calls `uninstall` if there are no remaining gated permissions
+   * granted. This is called by SitePermsAddonProvider when it gets a "perm-changed" notification for a gated
+   * permission.
+   *
+   * @param {nsIPermission} permission: The permission being added/removed
+   * @param {String} action: The action perm-changed notifies us about
+   */
+  handlePermissionChange(permission, action) {
+    if (action == "added") {
+      this.#permissionsByPermissionType.set(permission.type, permission);
+    } else if (action == "deleted") {
+      this.#permissionsByPermissionType.delete(permission.type);
+
+      if (this.#permissionsByPermissionType.size === 0) {
+        this.uninstall();
+      }
+    }
+  }
+
+  get type() {
+    return SITEPERMS_ADDON_TYPE;
+  }
+
+  get name() {
+    // TODO: Localize this string (See Bug 1790313).
+    return `Site permissions for ${this.principal.host}`;
+  }
+
+  get creator() {}
+
+  get homepageURL() {}
+
+  get description() {}
+
+  get fullDescription() {}
+
+  get version() {
+    // We consider the previous implementation attempt (signed addons) to be the initial version,
+    // hence the 2.0 for this approach.
+    return "2.0";
+  }
+  get updateDate() {}
+
+  get isActive() {
+    return true;
+  }
+
+  get appDisabled() {
+    return false;
+  }
+
+  get userDisabled() {
+    return false;
+  }
+  set userDisabled(aVal) {}
+
+  get size() {
+    return 0;
+  }
+
+  async updateBlocklistState(options = {}) {}
+
+  get blocklistState() {
+    return Ci.nsIBlocklistService.STATE_NOT_BLOCKED;
+  }
+
+  get scope() {
+    return lazy.AddonManager.SCOPE_APPLICATION;
+  }
+
+  get pendingOperations() {
+    return lazy.AddonManager.PENDING_NONE;
+  }
+
+  get operationsRequiringRestart() {
+    return lazy.AddonManager.OP_NEEDS_RESTART_NONE;
+  }
+
+  get permissions() {
+    // The addon only supports PERM_CAN_UNINSTALL and no other AOM permission.
+    return lazy.AddonManager.PERM_CAN_UNINSTALL;
+  }
+
+  get signedState() {
+    // Will make the permission prompt use the webextSitePerms.headerUnsignedWithPerms string
+    return lazy.AddonManager.SIGNEDSTATE_MISSING;
+  }
+
+  async enable() {}
+
+  async disable() {}
+
+  /**
+   * Uninstall the addon, calling AddonManager hooks and removing all granted permissions.
+   *
+   * @throws Services.perms.removeFromPrincipal could throw, see PermissionManager::AddInternal.
+   */
+  async uninstall() {
+    lazy.AddonManagerPrivate.callAddonListeners("onUninstalling", this, false);
+    for (const permission of this.#permissionsByPermissionType.values()) {
+      Services.perms.removeFromPrincipal(permission.principal, permission.type);
+    }
+    lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this);
+  }
+
+  get isCompatible() {
+    return true;
+  }
+
+  get isPlatformCompatible() {
+    return true;
+  }
+
+  get providesUpdatesSecurely() {
+    return true;
+  }
+
+  get foreignInstall() {
+    return false;
+  }
+
+  get installTelemetryInfo() {
+    return { source: "siteperm-addon-provider", method: "synthetic-install" };
+  }
+
+  isCompatibleWith(aAppVersion, aPlatformVersion) {
+    return true;
+  }
+}
+
+const SitePermsAddonProvider = {
+  get name() {
+    return "SitePermsAddonProvider";
+  },
+
+  wrappersMapByOrigin: new Map(),
+
+  /**
+   * Update wrappersMapByOrigin on perm-changed
+   *
+   * @param {nsIPermission} permission: The permission being added/removed
+   * @param {String} action: The action perm-changed notifies us about
+   */
+  handlePermissionChange(permission, action = "added") {
+    // Bail out if it it's not a gated perm
+    if (!isGatedPermissionType(permission.type)) {
+      return;
+    }
+
+    // Gated APIs should probably not be available on non-secure origins,
+    // but let's double check here.
+    if (permission.principal.scheme !== "https") {
+      return;
+    }
+
+    // Install origin cannot be on a known etld (e.g. github.io).
+    // We shouldn't get a permission change for those here, but let's
+    // be  extra safe
+    if (isKnownPublicSuffix(permission.principal.siteOrigin)) {
+      return;
+    }
+
+    const { siteOrigin } = permission.principal;
+
+    // Pipe the change to the existing addon is there is one.
+    if (this.wrappersMapByOrigin.has(siteOrigin)) {
+      this.wrappersMapByOrigin
+        .get(siteOrigin)
+        .handlePermissionChange(permission, action);
+    }
+
+    if (action == "added") {
+      // We only have one SitePermsAddon per origin, handling multiple permissions.
+      if (this.wrappersMapByOrigin.has(siteOrigin)) {
+        return;
+      }
+
+      const addonWrapper = new SitePermsAddonWrapper(siteOrigin, [permission]);
+      this.wrappersMapByOrigin.set(siteOrigin, addonWrapper);
+      return;
+    }
+
+    if (action == "deleted") {
+      if (!this.wrappersMapByOrigin.has(siteOrigin)) {
+        return;
+      }
+      // Only remove the addon if it doesn't have any permissions left.
+      if (this.wrappersMapByOrigin.get(siteOrigin).sitePermissions.length) {
+        return;
+      }
+      this.wrappersMapByOrigin.delete(siteOrigin);
+    }
+  },
+
+  /**
+   * Returns a Promise that resolves when handled the list of gated permissions
+   * and setup ther observer for the "perm-changed" event.
+   *
+   * @returns Promise
+   */
+  lazyInit() {
+    if (!this._initPromise) {
+      this._initPromise = new Promise(resolve => {
+        // Build the initial list of addons per origin
+        const perms = Services.perms.getAllByTypes(GATED_PERMISSIONS);
+        for (const perm of perms) {
+          this.handlePermissionChange(perm);
+        }
+        Services.obs.addObserver(this, "perm-changed");
+        resolve();
+      });
+    }
+    return this._initPromise;
+  },
+
+  shutdown() {
+    Services.obs.removeObserver(this, "perm-changed");
+    this.wrappersMapByOrigin.clear();
+    this._initPromise = null;
+  },
+
+  /**
+   * Get a SitePermsAddonWrapper from an extension id
+   *
+   * @param {String|null|undefined} id: The extension id,
+   * @returns {SitePermsAddonWrapper|undefined}
+   */
+  async getAddonByID(id) {
+    await this.lazyInit();
+    if (!id?.endsWith?.(SITEPERMS_ADDON_ID_SUFFIX)) {
+      return undefined;
+    }
+
+    for (const addon of this.wrappersMapByOrigin.values()) {
+      if (addon.id === id) {
+        return addon;
+      }
+    }
+    return undefined;
+  },
+
+  /**
+   * Get a list of SitePermsAddonWrapper for a given list of extension types.
+   *
+   * @param {Array<String>|null|undefined} types: If null or undefined is passed,
+   *        the callsites expect to get all the addons from the provider, without
+   *        any filtering.
+   * @returns {Array<SitePermsAddonWrapper>}
+   */
+  async getAddonsByTypes(types) {
+    if (
+      !this.isEnabled ||
+      // `types` can be null/undefined, and in such case we _do_ want to return the addons.
+      (Array.isArray(types) && !types.includes(SITEPERMS_ADDON_TYPE))
+    ) {
+      return [];
+    }
+
+    await this.lazyInit();
+    return Array.from(this.wrappersMapByOrigin.values());
+  },
+
+  get isEnabled() {
+    return Services.prefs.getBoolPref(SITEPERMS_ADDON_PROVIDER_PREF, false);
+  },
+
+  observe(subject, topic, data) {
+    if (topic == FIRST_CONTENT_PROCESS_TOPIC) {
+      Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+
+      lazy.AddonManagerPrivate.registerProvider(SitePermsAddonProvider, [
+        SITEPERMS_ADDON_TYPE,
+      ]);
+      Services.obs.notifyObservers(null, "sitepermsaddon-provider-registered");
+    } else if (topic === "perm-changed") {
+      const perm = subject.QueryInterface(Ci.nsIPermission);
+      this.handlePermissionChange(perm, data);
+    }
+  },
+
+  addFirstContentProcessObserver() {
+    Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC);
+  },
+};
+
+// We want to register the SitePermsAddonProvider once the first content process gets created.
+SitePermsAddonProvider.addFirstContentProcessObserver();
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -21,16 +21,23 @@ var EXPORTED_SYMBOLS = [
   "UpdateChecker",
   "XPIInstall",
   "verifyBundleSignedState",
 ];
 
 const { XPCOMUtils } = ChromeUtils.importESModule(
   "resource://gre/modules/XPCOMUtils.sys.mjs"
 );
+const {
+  computeSha256HashAsString,
+  getHashStringForCrypto,
+} = ChromeUtils.importESModule(
+  "resource://gre/modules/addons/crypto-utils.sys.mjs"
+);
+
 const { AppConstants } = ChromeUtils.import(
   "resource://gre/modules/AppConstants.jsm"
 );
 const { AddonManager, AddonManagerPrivate } = ChromeUtils.import(
   "resource://gre/modules/AddonManager.jsm"
 );
 
 const lazy = {};
@@ -782,31 +789,16 @@ function syncLoadManifest(state, locatio
 function getTemporaryFile() {
   let file = lazy.FileUtils.getDir(KEY_TEMPDIR, []);
   let random = Math.round(Math.random() * 36 ** 3).toString(36);
   file.append(`tmp-${random}.xpi`);
   file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, lazy.FileUtils.PERMS_FILE);
   return file;
 }
 
-/**
- * Returns the string representation (hex) of the SHA256 hash of `input`.
- *
- * @param {string} input
- *        The value to hash.
- * @returns {string}
- *          The hex representation of a SHA256 hash.
- */
-function computeSha256HashAsString(input) {
-  const data = new Uint8Array(new TextEncoder().encode(input));
-  const crypto = CryptoHash("sha256");
-  crypto.update(data, data.length);
-  return getHashStringForCrypto(crypto);
-}
-
 function getHashForFile(file, algorithm) {
   let crypto = CryptoHash(algorithm);
   let fis = new FileInputStream(file, -1, -1, false);
   try {
     crypto.updateFromStream(fis, file.fileSize);
   } finally {
     fis.close();
   }
@@ -1219,26 +1211,16 @@ SafeInstallOperation.prototype = {
     }
 
     while (this._createdDirs.length) {
       recursiveRemove(this._createdDirs.pop());
     }
   },
 };
 
-function getHashStringForCrypto(aCrypto) {
-  // return the two-digit hexadecimal code for a byte
-  let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
-
-  // convert the binary hash data to a hex string.
-  let binary = aCrypto.finish(/* base64 */ false);
-  let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
-  return hash.join("").toLowerCase();
-}
-
 // A hash algorithm if the caller of AddonInstall did not specify one.
 const DEFAULT_HASH_ALGO = "sha256";
 
 /**
  * Base class for objects that manage the installation of an addon.
  * This class isn't instantiated directly, see the derived classes below.
  */
 class AddonInstall {
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs
@@ -0,0 +1,41 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const CryptoHash = Components.Constructor(
+  "@mozilla.org/security/hash;1",
+  "nsICryptoHash",
+  "initWithString"
+);
+
+/**
+ * Returns the string representation (hex) of the SHA256 hash of `input`.
+ *
+ * @param {string} input
+ *        The value to hash.
+ * @returns {string}
+ *          The hex representation of a SHA256 hash.
+ */
+export function computeSha256HashAsString(input) {
+  const data = new Uint8Array(new TextEncoder().encode(input));
+  const crypto = CryptoHash("sha256");
+  crypto.update(data, data.length);
+  return getHashStringForCrypto(crypto);
+}
+
+/**
+ * Returns the string representation (hex) of a given CryptoHashInstance.
+ *
+ * @param {CryptoHash} aCrypto
+ * @returns {string}
+ *          The hex representation of a SHA256 hash.
+ */
+export function getHashStringForCrypto(aCrypto) {
+  // return the two-digit hexadecimal code for a byte
+  let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+  // convert the binary hash data to a hex string.
+  let binary = aCrypto.finish(/* base64 */ false);
+  let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+  return hash.join("").toLowerCase();
+}
--- a/toolkit/mozapps/extensions/internal/moz.build
+++ b/toolkit/mozapps/extensions/internal/moz.build
@@ -3,22 +3,26 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 EXTRA_JS_MODULES.addons += [
     "AddonRepository.jsm",
     "AddonSettings.jsm",
     "AddonUpdateChecker.jsm",
+    "crypto-utils.sys.mjs",
     "ProductAddonChecker.jsm",
+    "siteperms-addon-utils.sys.mjs",
     "XPIDatabase.jsm",
     "XPIInstall.jsm",
     "XPIProvider.jsm",
 ]
 
 if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android":
     EXTRA_JS_MODULES.addons += [
         "GMPProvider.sys.mjs",
+        ## TODO consider extending it to mobile builds too (See Bug 1790084).
+        "SitePermsAddonProvider.sys.mjs",
     ]
 
 TESTING_JS_MODULES += [
     "AddonTestUtils.jsm",
 ]
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export const GATED_PERMISSIONS = ["midi", "midi-sysex"];
+export const SITEPERMS_ADDON_PROVIDER_PREF =
+  "dom.sitepermsaddon-provider.enabled";
+export const SITEPERMS_ADDON_TYPE = "sitepermission";
+
+/**
+ * @param {string} type
+ * @returns {boolean}
+ */
+export function isGatedPermissionType(type) {
+  return GATED_PERMISSIONS.includes(type);
+}
+
+/**
+ * @param {string} siteOrigin
+ * @returns {boolean}
+ */
+export function isKnownPublicSuffix(siteOrigin) {
+  const { host } = new URL(siteOrigin);
+
+  let isPublic = false;
+  // getKnownPublicSuffixFromHost throws when passed an IP, in such case, assume
+  // this is not a public etld.
+  try {
+    isPublic = Services.eTLD.getKnownPublicSuffixFromHost(host) == host;
+  } catch (e) {}
+
+  return isPublic;
+}
+
+/**
+ * ⚠️ This should be only used for testing purpose ⚠️
+ *
+ * @param {Array<String>} permissionTypes
+ * @throws if not called from xpcshell test
+ */
+export function addGatedPermissionTypesForXpcShellTests(permissionTypes) {
+  let env = Cc["@mozilla.org/process/environment;1"].getService(
+    Ci.nsIEnvironment
+  );
+  if (!env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+    throw new Error("This should only be called from XPCShell tests");
+  }
+
+  GATED_PERMISSIONS.push(...permissionTypes);
+}
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -209,16 +209,17 @@ Object.defineProperty(this, "TEST_UNPACK
   },
   set(val) {
     AddonTestUtils.testUnpacked = val;
   },
 });
 
 const promiseAddonByID = AddonManager.getAddonByID;
 const promiseAddonsByIDs = AddonManager.getAddonsByIDs;
+const promiseAddonsByTypes = AddonManager.getAddonsByTypes;
 
 var gPort = null;
 
 var BootstrapMonitor = {
   started: new Map(),
   stopped: new Map(),
   installed: new Map(),
   uninstalled: new Map(),
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sitePermsAddonProvider.js
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+  addGatedPermissionTypesForXpcShellTests,
+  SITEPERMS_ADDON_PROVIDER_PREF,
+  SITEPERMS_ADDON_TYPE,
+} = ChromeUtils.importESModule(
+  "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs"
+);
+
+const { PermissionTestUtils } = ChromeUtils.import(
+  "resource://testing-common/PermissionTestUtils.jsm"
+);
+let ssm = Services.scriptSecurityManager;
+const PRINCIPAL_COM = ssm.createContentPrincipalFromOrigin(
+  "https://example.com"
+);
+const PRINCIPAL_ORG = ssm.createContentPrincipalFromOrigin(
+  "https://example.org"
+);
+const PRINCIPAL_GITHUB = ssm.createContentPrincipalFromOrigin(
+  "https://github.io"
+);
+const PRINCIPAL_UNSECURE = ssm.createContentPrincipalFromOrigin(
+  "http://example.net"
+);
+
+const GATED_SITE_PERM1 = "test/gatedSitePerm";
+const GATED_SITE_PERM2 = "test/anotherGatedSitePerm";
+addGatedPermissionTypesForXpcShellTests([GATED_SITE_PERM1, GATED_SITE_PERM2]);
+const NON_GATED_SITE_PERM = "test/nonGatedPerm";
+
+add_setup(async () => {
+  AddonTestUtils.init(this);
+
+  Services.prefs.setBoolPref(SITEPERMS_ADDON_PROVIDER_PREF, true);
+  registerCleanupFunction(() => {
+    Services.prefs.clearUserPref(SITEPERMS_ADDON_PROVIDER_PREF);
+  });
+
+  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
+  await promiseStartupManager();
+
+  // The SitePermsAddonProvider does not register until the first content process
+  // is launched, so we simulate that by firing this notification.
+  Services.obs.notifyObservers(null, "ipc:first-content-process-created");
+});
+
+add_task(async function() {
+  let addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+  Assert.equal(addons.length, 0, "There's no addons");
+
+  info("Add a gated permission");
+  PermissionTestUtils.add(
+    PRINCIPAL_COM,
+    GATED_SITE_PERM1,
+    Services.perms.ALLOW_ACTION
+  );
+  addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+  Assert.equal(addons.length, 1, "A siteperm addon is now available");
+  const comAddon = await promiseAddonByID(addons[0].id);
+  Assert.equal(addons[0], comAddon, "getAddonByID returns the expected addon");
+
+  Assert.deepEqual(
+    comAddon.sitePermissions,
+    [GATED_SITE_PERM1],
+    "addon has expected sitePermissions"
+  );
+  Assert.equal(comAddon.type, SITEPERMS_ADDON_TYPE, "addon has expected type");
+  Assert.equal(
+    comAddon.name,
+    `Site permissions for example.com`,
+    "addon has expected name"
+  );
+
+  info("Add another gated permission");
+  PermissionTestUtils.add(
+    PRINCIPAL_COM,
+    GATED_SITE_PERM2,
+    Services.perms.ALLOW_ACTION
+  );
+  addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+  Assert.equal(
+    addons.length,
+    1,
+    "There is no new siteperm addon after adding a permission to the same principal..."
+  );
+  Assert.deepEqual(
+    comAddon.sitePermissions,
+    [GATED_SITE_PERM1, GATED_SITE_PERM2],
+    "...but the new permission is reported by addon.sitePermissions"
+  );
+
+  info("Add a non-gated permission");
+  PermissionTestUtils.add(
+    PRINCIPAL_COM,
+    NON_GATED_SITE_PERM,
+    Services.perms.ALLOW_ACTION
+  );
+  Assert.equal(
+    addons.length,
+    1,
+    "There is no new siteperm addon after adding a non gated permission to the same principal..."
+  );
+  Assert.deepEqual(
+    comAddon.sitePermissions,
+    [GATED_SITE_PERM1, GATED_SITE_PERM2],
+    "...and the new permission is not reported by addon.sitePermissions"
+  );
+
+  info("Adding a gated permission to another principal");
+  PermissionTestUtils.add(
+    PRINCIPAL_ORG,
+    GATED_SITE_PERM1,
+    Services.perms.ALLOW_ACTION
+  );
+  addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+  Assert.equal(addons.length, 2, "A new siteperm addon is now available");
+  const orgAddon = await promiseAddonByID(addons[1].id);
+  Assert.equal(addons[1], orgAddon, "getAddonByID returns the expected addon");
+
+  Assert.deepEqual(
+    orgAddon.sitePermissions,
+    [GATED_SITE_PERM1],
+    "new addon only has a single sitePermission"
+  );
+
+  info("Passing null or undefined to getAddonsByTypes returns all the addons");
+  addons = await promiseAddonsByTypes(null);
+  // We can't do an exact check on all the returned addons as we get other type
+  // of addons from other providers.
+  Assert.deepEqual(
+    addons.filter(a => a.type == SITEPERMS_ADDON_TYPE).map(a => a.id),
+    [comAddon.id, orgAddon.id],
+    "Got site perms addons when passing null"
+  );
+
+  addons = await promiseAddonsByTypes();
+  Assert.deepEqual(
+    addons.filter(a => a.type == SITEPERMS_ADDON_TYPE).map(a => a.id),
+    [comAddon.id, orgAddon.id],
+    "Got site perms addons when passing undefined"
+  );
+
+  info("Remove a gated permission");
+  PermissionTestUtils.remove(PRINCIPAL_COM, GATED_SITE_PERM2);
+  addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+  Assert.equal(
+    addons.length,
+    2,
+    "Removing a permission did not removed the addon has it has another permission"
+  );
+  Assert.deepEqual(
+    comAddon.sitePermissions,
+    [GATED_SITE_PERM1],
+    "addon has expected sitePermissions"
+  );
+
+  info("Remove last gated permission on PRINCIPAL_COM");
+  const promisePrincipalComUninstalling = AddonTestUtils.promiseAddonEvent(
+    "onUninstalling",
+    addon => {
+      return addon.id === comAddon.id;
+    }
+  );
+  const promisePrincipalComUninstalled = AddonTestUtils.promiseAddonEvent(
+    "onUninstalled",
+    addon => {
+      return addon.id === comAddon.id;
+    }
+  );
+  PermissionTestUtils.remove(PRINCIPAL_COM, GATED_SITE_PERM1);
+  info("Wait for onUninstalling addon listener call");
+  await promisePrincipalComUninstalling;
+  info("Wait for onUninstalled addon listener call");
+  await promisePrincipalComUninstalled;
+
+  addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+  Assert.equal(
+    addons.length,
+    1,
+    "Removing the last gated permission removed the addon"
+  );
+  Assert.equal(addons[0], orgAddon);
+
+  info("Uninstall org addon");
+  orgAddon.uninstall();
+  addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+  Assert.equal(addons.length, 0, "org addon is removed");
+  Assert.equal(
+    PermissionTestUtils.testExactPermission(PRINCIPAL_ORG, GATED_SITE_PERM1),
+    false,
+    "Permission was removed when the addon was uninstalled"
+  );
+
+  info("Adding a permission to a public etld");
+  PermissionTestUtils.add(
+    PRINCIPAL_GITHUB,
+    GATED_SITE_PERM1,
+    Services.perms.ALLOW_ACTION
+  );
+  addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+  Assert.equal(
+    addons.length,
+    0,
+    "Adding a gated permission to a public etld shouldn't add a new addon"
+  );
+  // Cleanup
+  PermissionTestUtils.remove(PRINCIPAL_GITHUB, GATED_SITE_PERM1);
+
+  info("Adding a permission to a non secure principal");
+  PermissionTestUtils.add(
+    PRINCIPAL_UNSECURE,
+    GATED_SITE_PERM1,
+    Services.perms.ALLOW_ACTION
+  );
+  addons = await promiseAddonsByTypes([SITEPERMS_ADDON_TYPE]);
+  Assert.equal(
+    addons.length,
+    0,
+    "Adding a gated permission to an unsecure principal shouldn't add a new addon"
+  );
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -111,16 +111,17 @@ skip-if = true
 [test_signed_install.js]
 [test_signed_langpack.js]
 [test_signed_long.js]
 [test_signed_updatepref.js]
 skip-if =
   require_signing
   !allow_legacy_extensions
 [test_signed_verify.js]
+[test_sitePermsAddonProvider.js]
 [test_startup.js]
 head = head_addons.js head_sideload.js
 skip-if =
   os == "linux" # Bug 1613268
   condprof  # Bug 1769184 - by design for now
 [test_startup_enable.js]
 [test_startup_isPrivileged.js]
 [test_startup_scan.js]