CVE-2022-24841 (published 2022-04-18T22:15Z)

fleetdm/fleet is an open source device management, built on osquery. All versions of fleet making use of the teams feature are affected by this authorization bypass issue. Fleet instances without teams, or with teams but without restricted team accounts are not affected. In affected versions a team admin can erroneously add themselves as admin, maintainer or observer on other teams. Users are advised to upgrade to version 4.13. There are no known workarounds for this issue.

Severity: MEDIUM
CVSS v2 base score: 5.5 (AV:N/AC:L/Au:S/C:P/I:P/A:N)
CVSS v3 base score: 8.1, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N)

Weakness information:
cwe_id: CWE-863
cwe_name: Incorrect Authorization
description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are incorrectly applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/fleetdm/fleet
commit_details: repo_url: https://github.com/fleetdm/fleet
author: Lucas Manuel Rodriguez
author_date: 2022-04-18 14:27:30-03:00
msg: Merge pull request from GHSA-pr2g-j78h-84cr

* Fix access control issues with users

* Fix access control issues with packs

* Fix access control issues with software

* Changes suggested by Martin

* All users can access the global schedule

* Restrict access to activities

* Add explicit test for team admin escalation vuln

* All global users should be able to read all software

* Handbook editor pass - Security - GitHub Security (#5108)

* Update security.md

All edits are recorded by line:

395 replaced “open-source” with “open source”
411 replaced “open-source” with “open source”
439 added “the” before “comment”; replaced “repositories,” with “repositories”
445 deleted “being” before “located”
458 added “and” after “PR”
489 replaced “on” with “in”
493 replaced “open-source” with “open source”; Replaced “privileges,” with “privileges”

* Update security.md

line 479

* Update security.md

added (static analysis tools used to identify problems in code) to line 479

* Fix UI

* Fix UI

* revert api v1 to latest in documentation (#5149)

* revert api v1 to latest in documentation

* Update fleetctl doc page

Co-authored-by: Noah Talerman <noahtal@umich.edu>

* Add team admin team policy automation; fix e2e

* Update to company page of the handbook (#5164)

Updated "Why do we use a wireframe-first approach?" section of company.md

* removed extra data on smaller screens (#5154)

* Update for team automations; e2e

* Jira Integration: Cypress e2e tests only (#5055)

* Update company.md (#5170)

This is to update the formatting under "empathy" and to fix the spelling of "help text."
This was done as per @mikermcneil .
This is related to #https://github.com/fleetdm/fleet/pull/4941 and https://github.com/fleetdm/fleet/issues/4902

* fix update updated_at for aggregated_stats (#5112)

Update the updated_at column when using ON DUPLICATE UPDATE so that
the counts_updated_at is up to date

* basic sql formatting in code ie whitespace around operators

* Fix e2e test

* Fix tests in server/authz

Co-authored-by: gillespi314 <73313222+gillespi314@users.noreply.github.com>
Co-authored-by: Desmi-Dizney <99777687+Desmi-Dizney@users.noreply.github.com>
Co-authored-by: Michal Nicpon <39177923+michalnicp@users.noreply.github.com>
Co-authored-by: Noah Talerman <noahtal@umich.edu>
Co-authored-by: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com>
Co-authored-by: Martavis Parker <47053705+martavis@users.noreply.github.com>
Co-authored-by: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
repository: repo_url: https://github.com/fleetdm/fleet
repo_name: fleetdm/fleet
description: Open-source platform for IT, security, and infrastructure teams. (Linux, macOS, Chrome, Windows, cloud, data center)
repo_language: Go
owner: fleetdm
file_changes: filename: activities-rbac
change_type: ModificationType.ADD
diff: @@ -0,0 +1 @@
+* Restrict non-global user from access to activities
\ No newline at end of file

programming_language: SQL
filename: issue-GHSA-pr2g-j78h-84cr
change_type: ModificationType.ADD
diff: @@ -0,0 +1,3 @@
+* Fix access control issues with "user" endpoints.
+* Fix access control issues with "pack" endpoints.
+* Fix access control issues with "software" endpoints.

programming_language: Markdown
filename: maintainer.spec.ts
change_type: ModificationType.MODIFY
diff: @@ -189,31 +189,7 @@ describe(
     describe("Manage software page", () => {
       beforeEach(() => cy.visit("/software/manage"));
       it("should restrict global maintainer from 'Manage automations' button", () => {
-        it("hides manages software automations when all teams selected", () => {
-          cy.getAttached(".manage-software-page__header-wrap").within(() => {
-            cy.getAttached(".Select").within(() => {
-              cy.findByText(/all teams/i).should("exist");
-            });
-            cy.getAttached(".manage-software-page__header-wrap").within(() => {
-              cy.findByRole("button", {
-                name: /manage automations/i,
-              }).should("not.exist");
-            });
-          });
-        });
-        it("hides manage automations button when all teams not selected", () => {
-          cy.getAttached(".manage-software-page__header-wrap").within(() => {
-            cy.getAttached(".Select").within(() => {
-              cy.getAttached(".Select-control").click();
-              cy.getAttached(".Select-menu-outer").within(() => {
-                cy.findByText(/apples/i).should("exist");
-              });
-              cy.findByRole("button", {
-                name: /manage automations/i,
-              }).should("not.exist");
-            });
-          });
-        });
+        cy.findByText(/manage automations/).should("not.exist");
       });
     });
     describe("Query pages", () => {
@@ -274,9 +250,13 @@ describe(
         cy.loginWithCySession("mary@organization.com", "user123#");
         cy.visit("/policies/manage");
       });
-      it("allows maintainer to manage automations", () => {
-        cy.findByRole("button", { name: /manage automations/i }).click();
-        cy.findByRole("button", { name: /cancel/i }).click();
+      it("hides manage automations from maintainer", () => {
+        cy.getAttached(".button-wrap").within(() => {
+          cy.findByRole("button", { name: /add a policy/i }).should("exist");
+          cy.findByRole("button", { name: /manage automations/i }).should(
+            "not.exist"
+          );
+        });
       });
       it("allows maintainer to add a policy", () => {
         cy.findByRole("button", { name: /add a policy/i }).click();

programming_language: TypeScript
filename: observer.spec.ts
change_type: ModificationType.MODIFY
diff: @@ -209,12 +209,10 @@ describe("Free tier - Observer user", () => {
       cy.visit("/policies/manage");
     });
     it("hides manage automations button", () => {
-      cy.findByRole("button", { name: /manage automations/i }).should(
-        "not.exist"
-      );
+      cy.findByText(/manage automations/i).should("not.exist");
     });
     it("hides add a policy button", () => {
-      cy.findByRole("button", { name: /add a policy/i }).should("not.exist");
+      cy.findByText(/add a policy/).should("not.exist");
     });
     it("hides run, edit, or delete a policy", () => {
       cy.getAttached("tbody").within(() => {

programming_language: TypeScript
filename: admin.spec.ts
change_type: ModificationType.MODIFY
diff: @@ -564,6 +564,27 @@ describe("Premium tier - Global Admin user", () => {
       });
       cy.findByText(/successfully updated policy automations/i).should("exist");
     });
+    it("allows global admin to automate a team policy", () => {
+      cy.visit("/policies/manage");
+      cy.getAttached(".Select-control").within(() => {
+        cy.findByText(/all teams/i).click();
+      });
+      cy.getAttached(".Select-menu")
+        .contains(/apples/i)
+        .click();
+      cy.getAttached(".button-wrap")
+        .findByRole("button", { name: /manage automations/i })
+        .click();
+      cy.getAttached(".manage-automations-modal").within(() => {
+        cy.getAttached(".fleet-slider").click();
+        cy.getAttached(".fleet-checkbox__input").check({ force: true });
+        cy.getAttached("#webhook-url")
+          .clear()
+          .type("https://example.com/global_admin");
+        cy.findByText(/save/i).click();
+      });
+      cy.findByText(/successfully updated policy automations/i).should("exist");
+    });
     it("allows global admin to delete a team policy", () => {
       cy.visit("/policies/manage");
       cy.getAttached(".Select-control").within(() => {

programming_language: TypeScript
filename: maintainer.spec.ts
change_type: ModificationType.MODIFY
diff: @@ -201,32 +201,8 @@ describe("Premium tier - Maintainer user", () => {
     });
     describe("Manage software page", () => {
       beforeEach(() => cy.visit("/software/manage"));
-      it("should restrict global maintainer from 'Manage automations' button", () => {
-        it("hides manages software automations when all teams selected", () => {
-          cy.getAttached(".manage-software-page__header-wrap").within(() => {
-            cy.getAttached(".Select").within(() => {
-              cy.findByText(/all teams/i).should("exist");
-            });
-            cy.getAttached(".manage-software-page__header-wrap").within(() => {
-              cy.findByRole("button", {
-                name: /manage automations/i,
-              }).should("not.exist");
-            });
-          });
-        });
-        it("hides manage automations button when all teams not selected", () => {
-          cy.getAttached(".manage-software-page__header-wrap").within(() => {
-            cy.getAttached(".Select").within(() => {
-              cy.getAttached(".Select-control").click();
-              cy.getAttached(".Select-menu-outer").within(() => {
-                cy.findByText(/apples/i).should("exist");
-              });
-              cy.findByRole("button", {
-                name: /manage automations/i,
-              }).should("not.exist");
-            });
-          });
-        });
+      it("hides 'Manage automations' button from global maintainer", () => {
+        cy.findByText(/manage automations/i).should("not.exist");
       });
     });
     describe("Query pages", () => {
@@ -250,11 +226,8 @@ describe("Premium tier - Maintainer user", () => {
     });
     describe("Manage policies page", () => {
       beforeEach(() => cy.visit("/policies/manage"));
-      it("allows global maintainer to click 'Manage automations' button", () => {
-        cy.getAttached(".button-wrap")
-          .findByRole("button", { name: /manage automations/i })
-          .click();
-        cy.findByRole("button", { name: /cancel/i }).click();
+      it("hides manage automations button", () => {
+        cy.findByText(/manage hosts/i).should("not.exist");
       });
       it("allows global maintainer to add a new policy", () => {
         cy.getAttached(".button-wrap")

programming_language: TypeScript
filename: team_admin.spec.ts
change_type: ModificationType.MODIFY
diff: @@ -192,10 +192,7 @@ describe("Premium tier - Team Admin user", () => {
   });
   describe("Manage software page", () => {
     beforeEach(() => cy.visit("/software/manage"));
-    it("hides manage automations button since all teams not selected", () => {
-      cy.getAttached(".manage-software-page__header-wrap").within(() => {
-        cy.findByText(/apples/i).should("exist");
-      });
+    it("hides manage automations button", () => {
       cy.findByText(/manage automations/i).should("not.exist");
     });
   });
@@ -283,12 +280,6 @@ describe("Premium tier - Team Admin user", () => {
   });
   describe("Manage policies page", () => {
     beforeEach(() => cy.visit("/policies/manage"));
-    it("hides manage automations button when all teams not selected", () => {
-      cy.getAttached(".manage-policies-page__header-wrap").within(() => {
-        cy.findByText(/apples/i).should("exist");
-      });
-      cy.findByText(/manage automations/i).should("not.exist");
-    });
     it("allows team admin to add a new policy", () => {
       cy.getAttached(".button-wrap")
         .findByRole("button", { name: /add a policy/i })
@@ -358,7 +349,7 @@ describe("Premium tier - Team Admin user", () => {
       cy.findByText(/apples/i).should("exist");
     });
     it("displays the team admin controls", () => {
-      cy.findByRole("button", { name: /add member/i }).click();
+      cy.findByRole("button", { name: /create user/i }).click();
       cy.findByRole("button", { name: /cancel/i }).click();
       cy.findByRole("button", { name: /add hosts/i }).click();
       cy.findByRole("button", { name: /done/i }).click();

programming_language: TypeScript
filename: team_maintainer_observer.spec.ts
change_type: ModificationType.MODIFY
diff: @@ -276,8 +276,8 @@ describe("Premium tier - Team observer/maintainer user", () => {
         cy.findByText(/apples/i).click();
         cy.findByText(/oranges/i).click();
 
-        // On maintaining team, should see the "Manage automations" button
-        cy.findByText(/manage automations/i).should("exist");
+        // On maintaining team, should not see the "Manage automations" button
+        cy.findByText(/manage automations/i).should("not.exist");
         // On maintaining team, should see "add a policy" and "save" a policy
         cy.findByText(/add a policy/i).click();
 

programming_language: TypeScript
filename: Software.tsx
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,8 @@
-import React, { useState } from "react";
+import React, { useContext, useState } from "react";
 import { useQuery } from "react-query";
 import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
 
+import { AppContext } from "context/app";
 import paths from "router/paths";
 import configAPI from "services/entities/config";
 import softwareAPI, { ISoftwareResponse } from "services/entities/software";
@@ -39,6 +40,10 @@ const Software = ({
   const [pageIndex, setPageIndex] = useState<number>(0);
   const [isSoftwareEnabled, setIsSoftwareEnabled] = useState<boolean>();
 
+  const { availableTeams, currentTeam, isOnGlobalTeam } = useContext(
+    AppContext
+  );
+
   const { data: config } = useQuery(["config"], configAPI.loadAll, {
     onSuccess: (data) => {
       setIsSoftwareEnabled(data?.host_settings?.enable_software_inventory);
@@ -71,8 +76,11 @@ const Software = ({
         teamId: currentTeamId,
       }),
     {
+      enabled:
+        isOnGlobalTeam ||
+        !!availableTeams?.find((t) => t.id === currentTeam?.id),
       keepPreviousData: true,
-      staleTime: 30000, // TODO: Discuss a reasonable staleTime given that counts are only updated infrequently?
+      staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval
       onSuccess: (data) => {
         setShowSoftwareUI(true);
         if (isSoftwareEnabled && data.software?.length !== 0) {

programming_language: TypeScript
filename: MembersPage.tsx
change_type: ModificationType.MODIFY
diff: @@ -51,9 +51,13 @@ const MembersPage = ({
   const teamId = parseInt(team_id, 10);
 
   const { renderFlash } = useContext(NotificationContext);
-  const { config, isGlobalAdmin, currentUser, isPremiumTier } = useContext(
-    AppContext
-  );
+  const {
+    config,
+    currentUser,
+    isGlobalAdmin,
+    isPremiumTier,
+    isTeamAdmin,
+  } = useContext(AppContext);
 
   const smtpConfigured = config?.smtp_settings.configured || false;
   const canUseSso = config?.sso_settings.enable_sso || false;
@@ -137,14 +141,7 @@ const MembersPage = ({
   const toggleCreateMemberModal = useCallback(() => {
     setShowCreateUserModal(!showCreateUserModal);
     setShowAddMemberModal(false);
-    currentUser ? setUserEditing(currentUser) : setUserEditing(undefined);
-  }, [
-    showCreateUserModal,
-    currentUser,
-    setShowCreateUserModal,
-    setUserEditing,
-    setShowAddMemberModal,
-  ]);
+  }, [showCreateUserModal, setShowCreateUserModal, setShowAddMemberModal]);
 
   // FUNCTIONS
 
@@ -153,7 +150,10 @@ const MembersPage = ({
     teamsAPI
       .removeMembers(teamId, removedUsers)
       .then(() => {
-        renderFlash("success", `Successfully removed ${userEditing?.name}`);
+        renderFlash(
+          "success",
+          `Successfully removed ${userEditing?.name || "member"}`
+        );
         // If user removes self from team, redirect to home
         if (currentUser && currentUser.id === removedUsers.users[0].id) {
           window.location.href = "/";
@@ -178,12 +178,15 @@ const MembersPage = ({
     (newMembers: INewMembersBody) => {
       teamsAPI
         .addMembers(teamId, newMembers)
-        .then(() =>
+        .then(() => {
+          const count = newMembers.users.length;
           renderFlash(
             "success",
-            `${newMembers.users.length} members successfully added to ${currentTeam?.name}.`
-          )
-        )
+            `${count} ${
+              count === 1 ? "member" : "members"
+            } successfully added to ${currentTeam?.name}.`
+          );
+        })
         .catch(() =>
           renderFlash("error", "Could not add members. Please try again.")
         )
@@ -288,7 +291,10 @@ const MembersPage = ({
         usersAPI
           .update(userEditing.id, updatedAttrs)
           .then(() => {
-            renderFlash("success", `Successfully edited ${userName}.`);
+            renderFlash(
+              "success",
+              `Successfully edited ${userName || "member"}.`
+            );
 
             if (
               currentUser &&
@@ -317,7 +323,7 @@ const MembersPage = ({
             } else {
               renderFlash(
                 "error",
-                `Could not edit ${userName}. Please try again.`
+                `Could not edit ${userName || "member"}. Please try again.`
               );
             }
           });
@@ -349,13 +355,24 @@ const MembersPage = ({
                   Expecting to see new team members listed here? Try again in a
                   few seconds as the system catches up.
                 </p>
-                <Button
-                  variant="brand"
-                  className={`${noMembersClass}__create-button`}
-                  onClick={toggleAddUserModal}
-                >
-                  Add member
-                </Button>
+                {isGlobalAdmin && (
+                  <Button
+                    variant="brand"
+                    className={`${noMembersClass}__create-button`}
+                    onClick={toggleAddUserModal}
+                  >
+                    Add member
+                  </Button>
+                )}
+                {isTeamAdmin && (
+                  <Button
+                    variant="brand"
+                    className={`${noMembersClass}__create-button`}
+                    onClick={toggleCreateMemberModal}
+                  >
+                    Create user
+                  </Button>
+                )}
               </>
             ) : (
               <>
@@ -396,8 +413,10 @@ const MembersPage = ({
           isLoading={isLoadingMembers}
           defaultSortHeader={"name"}
           defaultSortDirection={"asc"}
-          onActionButtonClick={toggleAddUserModal}
-          actionButtonText={"Add member"}
+          onActionButtonClick={
+            isGlobalAdmin ? toggleAddUserModal : toggleCreateMemberModal
+          }
+          actionButtonText={isGlobalAdmin ? "Add member" : "Create user"}
           actionButtonVariant={"brand"}
           hideActionButton={memberIds.length === 0 && searchString === ""}
           onQueryChange={({ searchQuery }) => setSearchString(searchQuery)}
@@ -424,7 +443,7 @@ const MembersPage = ({
           onSubmit={onEditMemberSubmit}
           defaultName={userEditing?.name}
           defaultEmail={userEditing?.email}
-          defaultGlobalRole={userEditing?.global_role}
+          defaultGlobalRole={userEditing?.global_role || null}
           defaultTeamRole={userEditing?.role}
           defaultTeams={userEditing?.teams}
           availableTeams={teams || []}

programming_language: TypeScript
filename: UserForm.tsx
change_type: ModificationType.MODIFY
diff: @@ -121,7 +121,7 @@ const UserForm = ({
   });
 
   const [isGlobalUser, setIsGlobalUser] = useState<boolean>(
-    defaultGlobalRole !== null
+    !!defaultGlobalRole
   );
 
   useEffect(() => {

programming_language: TypeScript
filename: ManagePoliciesPage.tsx
change_type: ModificationType.MODIFY
diff: @@ -138,6 +138,7 @@ const ManagePolicyPage = ({
 
   const canAddOrRemovePolicy =
     isGlobalAdmin || isGlobalMaintainer || isTeamMaintainer || isTeamAdmin;
+  const canManageAutomations = isGlobalAdmin || isTeamAdmin;
 
   const { isLoading: isLoadingWebhooks, refetch: refetchWebhooks } = useQuery<
     IConfig | ILoadTeamResponse,
@@ -364,7 +365,7 @@ const ManagePolicyPage = ({
             </div>
           </div>
           <div className={`${baseClass} button-wrap`}>
-            {canAddOrRemovePolicy &&
+            {canManageAutomations &&
               !isLoadingWebhooks &&
               !isLoadingGlobalPolicies && (
                 <Button

programming_language: TypeScript
filename: ManageSoftwarePage.tsx
change_type: ModificationType.MODIFY
diff: @@ -68,9 +68,9 @@ const ManageSoftwarePage = ({
   const {
     availableTeams,
     currentTeam,
-    setConfig,
     isGlobalAdmin,
     isGlobalMaintainer,
+    isOnGlobalTeam,
   } = useContext(AppContext);
   const { renderFlash } = useContext(NotificationContext);
 
@@ -162,6 +162,9 @@ const ManageSoftwarePage = ({
       return softwareAPI.load(params);
     },
     {
+      enabled:
+        isOnGlobalTeam ||
+        !!availableTeams?.find((t) => t.id === currentTeam?.id),
       keepPreviousData: true,
       staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval
     }
@@ -190,6 +193,9 @@ const ManageSoftwarePage = ({
       });
     },
     {
+      enabled:
+        isOnGlobalTeam ||
+        !!availableTeams?.find((t) => t.id === currentTeam?.id),
       keepPreviousData: true,
       staleTime: 30000, // stale time can be adjusted if fresher data is desired based on software inventory interval
       refetchOnWindowFocus: false,

programming_language: TypeScript
filename: authz.go
change_type: ModificationType.MODIFY
diff: @@ -138,6 +138,12 @@ type AuthzTyper interface {
 	AuthzType() string
 }
 
+// ExtraAuthzer is the interface to implement extra fields for the policy.
+type ExtraAuthzer interface {
+	// ExtraAuthz returns the extra key/value pairs for the type.
+	ExtraAuthz() (map[string]interface{}, error)
+}
+
 // jsonToInterface turns any type that can be JSON (un)marshaled into an
 // map[string]interface{} for evaluation by the OPA engine. Nil is returned as nil.
 func jsonToInterface(in interface{}) (interface{}, error) {
@@ -169,6 +175,19 @@ func jsonToInterface(in interface{}) (interface{}, error) {
 	if typer, ok := in.(AuthzTyper); ok {
 		out["type"] = typer.AuthzType()
 	}
+	// Add any extra key/values defined by the type.
+	if extra, ok := in.(ExtraAuthzer); ok {
+		extraKVs, err := extra.ExtraAuthz()
+		if err != nil {
+			return nil, fmt.Errorf("extra authz: %w", err)
+		}
+		for k, v := range extraKVs {
+			if _, ok := out[k]; ok {
+				return nil, fmt.Errorf("existing authz value: %s", k)
+			}
+			out[k] = v
+		}
+	}
 
 	return out, nil
 }

programming_language: Go
filename: policy.rego
change_type: ModificationType.MODIFY
diff: @@ -13,8 +13,12 @@ import input.subject
 read := "read"
 list := "list"
 write := "write"
+
+# User specific actions
 write_role := "write_role"
 change_password := "change_password"
+
+# Query specific actions
 run := "run"
 run_new := "run_new"
 
@@ -95,35 +99,31 @@ allow {
 
 ##
 # Users
+#
+# NOTE: More rules apply to users but they are implemented in Go code.
+# Our end goal is to move all the authorization logic here.
 ##
 
-# Any user can write self (besides role) and change their own password.
+# Any user can read and write self and change their own password.
 allow {
   object.type == "user"
   object.id == subject.id
   object.id != 0
-  action == [write, change_password][_]
+  action == [read, write, change_password][_]
 }
 
-# Any user can read other users.
-allow {
-  object.type == "user"
-  not is_null(subject)
-  action == read
-}
-
-# Admins can write all users + roles + change passwords.
+# Global admins can perform all operations on all users.
 allow {
   object.type == "user"
   subject.global_role == admin
-  action == [write, write_role, change_password][_]
+  action == [read, write, write_role, change_password][_]
 }
 
-## Team admins can create or edit new users, but not change their password.
+# Team admins can perform all operations on the team users (except changing their password).
 allow {
   object.type == "user"
   team_role(subject, object.teams[_].id) == admin
-  action == [write, write_role][_]
+  action == [read, write, write_role][_]
 }
 
 ##
@@ -141,9 +141,9 @@ allow {
 # Activities
 ##
 
-# All users can read activities
+# Only global users can read activities
 allow {
-  not is_null(subject)
+  not is_null(subject.global_role)
   object.type == "activity"
   action == read
 }
@@ -402,26 +402,41 @@ allow {
 # Packs
 ##
 
-# Global admins and maintainers can read/write all packs
+# Global admins and maintainers can read/write all packs.
 allow {
   object.type == "pack"
-  subject.global_role == [admin,maintainer][_]
+  subject.global_role == [admin, maintainer][_]
   action == [read, write][_]
 }
 
-# Team admins and maintainers can read global packs
+# All users can read the global pack.
 allow {
-  is_null(object.team_ids)
   object.type == "pack"
-  team_role(subject, subject.teams[_].id) == [admin,maintainer][_]
+  not is_null(subject)
+  object.is_global_pack == true
   action == read
 }
 
-# Team admins and maintainers can read/write their team packs
+# Team admins, maintainers and observers can read their team's pack.
+#
+# NOTE: Action "read" on a team's pack includes listing its scheduled queries.
 allow {
   object.type == "pack"
-  team_role(subject, object.team_ids[_]) == [admin,maintainer][_]
-  action == [read, write][_]
+  not is_null(object.pack_team_id)
+  team_role(subject, object.pack_team_id) == [admin, maintainer, observer][_]
+  action == read
+}
+
+# Team admins and maintainers can add/remove scheduled queries from/to their team's pack.
+#
+# NOTE: The team's pack is not editable per-se, it's a special pack to group
+# all the team's scheduled queries. So the "write" operation only covers
+# adding/removing scheduled queries from the pack.
+allow {
+  object.type == "pack"
+  not is_null(object.pack_team_id)
+  team_role(subject, object.pack_team_id) == [admin, maintainer][_]
+  action == write
 }
 
 ##
@@ -481,9 +496,17 @@ allow {
 # Software
 ##
 
-# All users can read software
+# Global users can read all software.
 allow {
-  not is_null(subject)
-  object.type == "software"
+  object.type == "software_inventory"
+  subject.global_role == [admin, maintainer, observer][_]
   action == read
 }
+
+# Team users can read all software in their teams.
+allow {
+  not is_null(object.team_id)
+  object.type == "software_inventory"
+  team_role(subject, object.team_id) == [admin, maintainer, observer][_]
+  action == read
+}
\ No newline at end of file

programming_language: Scala
filename: policy_test.go
change_type: ModificationType.MODIFY
diff: @@ -115,7 +115,7 @@ func TestAuthorizeUser(t *testing.T) {
 		{user: nil, object: user, action: changePwd, allow: false},
 		{user: nil, object: newUser, action: write, allow: false},
 
-		// Admin can read/write all and create new
+		// Global admin can read/write all and create new.
 		{user: test.UserAdmin, object: user, action: read, allow: true},
 		{user: test.UserAdmin, object: user, action: write, allow: true},
 		{user: test.UserAdmin, object: user, action: writeRole, allow: true},
@@ -126,48 +126,60 @@ func TestAuthorizeUser(t *testing.T) {
 		{user: test.UserAdmin, object: test.UserAdmin, action: writeRole, allow: true},
 		{user: test.UserAdmin, object: test.UserAdmin, action: changePwd, allow: true},
 
-		// Regular users can read all users and write self (besides roles), but not create
-		{user: test.UserMaintainer, object: user, action: read, allow: true},
+		// Global maintainers cannot read/write users.
+		{user: test.UserMaintainer, object: user, action: read, allow: false},
 		{user: test.UserMaintainer, object: user, action: write, allow: false},
 		{user: test.UserMaintainer, object: user, action: writeRole, allow: false},
 		{user: test.UserMaintainer, object: user, action: changePwd, allow: false},
+		// Global maintainers cannot create users.
 		{user: test.UserMaintainer, object: newUser, action: write, allow: false},
+		// Global maintainers can read/write itself (besides roles).
 		{user: test.UserMaintainer, object: test.UserMaintainer, action: read, allow: true},
 		{user: test.UserMaintainer, object: test.UserMaintainer, action: write, allow: true},
 		{user: test.UserMaintainer, object: test.UserMaintainer, action: writeRole, allow: false},
 		{user: test.UserMaintainer, object: test.UserMaintainer, action: changePwd, allow: true},
 
-		{user: test.UserNoRoles, object: user, action: read, allow: true},
+		// Users without roles cannot read/write users.
+		{user: test.UserNoRoles, object: user, action: read, allow: false},
 		{user: test.UserNoRoles, object: user, action: write, allow: false},
 		{user: test.UserNoRoles, object: user, action: writeRole, allow: false},
 		{user: test.UserNoRoles, object: user, action: changePwd, allow: false},
+		// User without roles cannot add new users.
 		{user: test.UserNoRoles, object: newUser, action: write, allow: false},
+		// User without roles can read/write itself (besides roles).
 		{user: test.UserNoRoles, object: test.UserNoRoles, action: read, allow: true},
 		{user: test.UserNoRoles, object: test.UserNoRoles, action: write, allow: true},
 		{user: test.UserNoRoles, object: test.UserNoRoles, action: writeRole, allow: false},
 		{user: test.UserNoRoles, object: test.UserNoRoles, action: changePwd, allow: true},
 
-		{user: test.UserObserver, object: user, action: read, allow: true},
+		// Global observers cannot read/write users.
+		{user: test.UserObserver, object: user, action: read, allow: false},
 		{user: test.UserObserver, object: user, action: write, allow: false},
 		{user: test.UserObserver, object: user, action: writeRole, allow: false},
 		{user: test.UserObserver, object: user, action: changePwd, allow: false},
+		// Global observers cannot create users.
 		{user: test.UserObserver, object: newUser, action: write, allow: false},
+		// Global observers can read/write itself (besides roles).
 		{user: test.UserObserver, object: test.UserObserver, action: read, allow: true},
 		{user: test.UserObserver, object: test.UserObserver, action: write, allow: true},
 		{user: test.UserObserver, object: test.UserObserver, action: writeRole, allow: false},
 		{user: test.UserObserver, object: test.UserObserver, action: changePwd, allow: true},
 
-		// Team Admin can create/write/write role of any team user, but not change password
-		{user: teamAdmin, object: user, action: read, allow: true},
+		// Team admins cannot read/write global users.
+		{user: teamAdmin, object: user, action: read, allow: false},
 		{user: teamAdmin, object: user, action: write, allow: false},
 		{user: teamAdmin, object: user, action: writeRole, allow: false},
 		{user: teamAdmin, object: user, action: changePwd, allow: false},
+		// Team admins cannot create new global users.
 		{user: teamAdmin, object: newUser, action: write, allow: false},
+		// Team admins can read/write team users (except change their password).
 		{user: teamAdmin, object: teamObserver, action: read, allow: true},
 		{user: teamAdmin, object: teamObserver, action: write, allow: true},
 		{user: teamAdmin, object: teamObserver, action: writeRole, allow: true},
 		{user: teamAdmin, object: teamObserver, action: changePwd, allow: false},
+		// Team admins can add new users to the team.
 		{user: teamAdmin, object: newTeamUser, action: write, allow: true},
+		// Team admins can read/write itself.
 		{user: teamAdmin, object: teamAdmin, action: read, allow: true},
 		{user: teamAdmin, object: teamAdmin, action: write, allow: true},
 		{user: teamAdmin, object: teamAdmin, action: writeRole, allow: true},
@@ -622,25 +634,25 @@ func TestAuthorizeTeamPacks(t *testing.T) {
 		{
 			user: test.UserTeamMaintainerTeam1,
 			object: &fleet.Pack{
-				TeamIDs: []uint{1},
+				Type: ptr.String("team-1"),
 			},
 			action: read,
 			allow:  true,
 		},
-		// Team observer cannot read packs of the team.
+		// Team observer can read packs of the team.
 		{
 			user: test.UserTeamObserverTeam1TeamAdminTeam2,
 			object: &fleet.Pack{
-				TeamIDs: []uint{1},
+				Type: ptr.String("team-1"),
 			},
 			action: read,
-			allow:  false,
+			allow:  true,
 		},
 		// Team observer cannot write packs of the team.
 		{
 			user: test.UserTeamObserverTeam1TeamAdminTeam2,
 			object: &fleet.Pack{
-				TeamIDs: []uint{1},
+				Type: ptr.String("team-1"),
 			},
 			action: write,
 			allow:  false,
@@ -649,7 +661,7 @@ func TestAuthorizeTeamPacks(t *testing.T) {
 		{
 			user: test.UserTeamAdminTeam1,
 			object: &fleet.Pack{
-				TeamIDs: []uint{2},
+				Type: ptr.String("team-2"),
 			},
 			action: read,
 			allow:  false,
@@ -658,24 +670,24 @@ func TestAuthorizeTeamPacks(t *testing.T) {
 		{
 			user: test.UserTeamAdminTeam1,
 			object: &fleet.Pack{
-				TeamIDs: []uint{2},
+				Type: ptr.String("team-2"),
 			},
 			action: read,
 			allow:  false,
 		},
-		// Team maintainers can read global packs.
+		// Team maintainers cannot read global packs.
 		{
 			user:   test.UserTeamMaintainerTeam1,
 			object: &fleet.Pack{},
 			action: read,
-			allow:  true,
+			allow:  false,
 		},
-		// Team admins can read global packs.
+		// Team admins cannot read global packs.
 		{
 			user:   test.UserTeamAdminTeam1,
 			object: &fleet.Pack{},
 			action: read,
-			allow:  true,
+			allow:  false,
 		},
 		// Team admins cannot write global packs.
 		{

programming_language: Go
filename: authz.go
change_type: ModificationType.MODIFY
diff: @@ -7,12 +7,26 @@ const (
 	ActionList = "list"
 	// ActionWrite refers to writing (CRUD operations) an entity.
 	ActionWrite = "write"
+
+	//
+	// User specific actions
+	//
+
 	// ActionWriteRole is a write to a user's global roles and teams.
+	//
+	// While the Write action allows setting the role on creation of a user, the
+	// ActionWriteRole action is required to modify an existing user's password.
 	ActionWriteRole = "write_role"
-	// ActionChangePassword is the permission to change a user's password. While
-	// the Write action allows setting the password on creation of a user, the
-	// ChangePassword action is required to modify an existing user's password.
+	// ActionChangePassword is the permission to change a user's password.
+	//
+	// While the Write action allows setting the password on creation of a user, the
+	// ActionChangePassword action is required to modify an existing user's password.
 	ActionChangePassword = "change_password"
+
+	//
+	// Query specific actions
+	//
+
 	// ActionRun is the action for running a live query.
 	ActionRun = "run"
 	// ActionRunNew is the action for running a new live query.

programming_language: Go
filename: packs.go
change_type: ModificationType.MODIFY
diff: @@ -2,6 +2,10 @@ package fleet
 
 import (
 	"errors"
+	"strconv"
+	"strings"
+
+	"github.com/fleetdm/fleet/v4/server/ptr"
 )
 
 type PackListOptions struct {
@@ -12,20 +16,65 @@ type PackListOptions struct {
 }
 
 // Pack is the structure which represents an osquery query pack.
+//
+// NOTE: A "team pack" is a special type of pack with Pack.Type="team-$TEAM_ID".
+// Such team packs hold the scheduled queries for a team. This is different from a
+// pack that has a team as target (Pack.Teams and Pack.TeamIDs fields).
 type Pack struct {
 	UpdateCreateTimestamps
-	ID          uint     `json:"id"`
-	Name        string   `json:"name"`
-	Description string   `json:"description,omitempty"`
-	Platform    string   `json:"platform,omitempty"`
-	Disabled    bool     `json:"disabled"`
-	Type        *string  `json:"type" db:"pack_type"`
-	Labels      []Target `json:"labels"`
-	LabelIDs    []uint   `json:"label_ids"`
-	Hosts       []Target `json:"hosts"`
-	HostIDs     []uint   `json:"host_ids"`
-	Teams       []Target `json:"teams"`
-	TeamIDs     []uint   `json:"team_ids"`
+	ID          uint   `json:"id"`
+	Name        string `json:"name"`
+	Description string `json:"description,omitempty"`
+	Platform    string `json:"platform,omitempty"`
+	Disabled    bool   `json:"disabled"`
+	// Type indicates the type of the pack:
+	//	- "global" is the type of the global pack.
+	//	- "team-$ID" is the type for team packs.
+	//	- nil is the type for a user created pack.
+	Type     *string  `json:"type" db:"pack_type"`
+	Labels   []Target `json:"labels"`
+	LabelIDs []uint   `json:"label_ids"`
+	Hosts    []Target `json:"hosts"`
+	HostIDs  []uint   `json:"host_ids"`
+	Teams    []Target `json:"teams"`
+	// TeamIDs holds the ID of the teams this pack should target.
+	TeamIDs []uint `json:"team_ids"`
+}
+
+// isTeamPack returns true if the pack is a pack specifically made for a team.
+func (p *Pack) isTeamPack() bool {
+	return p.Type != nil && strings.HasPrefix(*p.Type, "team-")
+}
+
+// isGlobalPack returns true if the pack is the global pack.
+func (p *Pack) isGlobalPack() bool {
+	return p.Type != nil && *p.Type == "global"
+}
+
+// TeamPack returns the team ID for a team's pack.
+// Returns (nil, nil) if the pack is not a team pack.
+func (p *Pack) teamPack() (*uint, error) {
+	if !p.isTeamPack() {
+		return nil, nil
+	}
+	t := strings.TrimPrefix(*p.Type, "team-")
+	teamID, err := strconv.ParseUint(t, 10, 64)
+	if err != nil {
+		return nil, err
+	}
+	return ptr.Uint(uint(teamID)), nil
+}
+
+// ExtraAuthz implements authz.ExtraAuthzer.
+func (p *Pack) ExtraAuthz() (map[string]interface{}, error) {
+	packTeamID, err := p.teamPack()
+	if err != nil {
+		return nil, err
+	}
+	return map[string]interface{}{
+		"pack_team_id":   packTeamID,
+		"is_global_pack": p.isGlobalPack(),
+	}, nil
 }
 
 // Verify verifies the pack's fields are valid.

programming_language: Go
filename: software.go
change_type: ModificationType.MODIFY
diff: @@ -43,6 +43,17 @@ func (Software) AuthzType() string {
 	return "software"
 }
 
+// AuthzSoftwareInventory is used for access controls on software inventory.
+type AuthzSoftwareInventory struct {
+	// TeamID is the ID of the team. A value of nil means global scope.
+	TeamID *uint `json:"team_id"`
+}
+
+// AuthzType implements authz.AuthzTyper.
+func (s *AuthzSoftwareInventory) AuthzType() string {
+	return "software_inventory"
+}
+
 type VulnerabilitiesSlice []SoftwareCVE
 
 // HostSoftware is the set of software installed on a specific host

programming_language: Go
filename: activities_test.go
change_type: ModificationType.MODIFY
diff: @@ -15,6 +15,9 @@ func TestListActivities(t *testing.T) {
 	ds := new(mock.Store)
 	svc := newTestService(t, ds, nil, nil)
 
+	globalUsers := []*fleet.User{test.UserAdmin, test.UserMaintainer, test.UserObserver}
+	teamUsers := []*fleet.User{test.UserTeamAdminTeam1, test.UserTeamMaintainerTeam1, test.UserTeamObserverTeam1}
+
 	ds.ListActivitiesFunc = func(ctx context.Context, opts fleet.ListOptions) ([]*fleet.Activity, error) {
 		return []*fleet.Activity{
 			{ID: 1},
@@ -22,15 +25,24 @@ func TestListActivities(t *testing.T) {
 		}, nil
 	}
 
-	// admin user
-	activities, err := svc.ListActivities(test.UserContext(test.UserAdmin), fleet.ListOptions{})
-	require.NoError(t, err)
-	require.Len(t, activities, 2)
+	// any global user can read activities
+	for _, u := range globalUsers {
+		activities, err := svc.ListActivities(test.UserContext(u), fleet.ListOptions{})
+		require.NoError(t, err)
+		require.Len(t, activities, 2)
+	}
+
+	// team users cannot read activities
+	for _, u := range teamUsers {
+		_, err := svc.ListActivities(test.UserContext(u), fleet.ListOptions{})
+		require.Error(t, err)
+		require.Contains(t, err.Error(), authz.ForbiddenErrorMessage)
+	}
 
-	// anyone can read activities
-	activities, err = svc.ListActivities(test.UserContext(test.UserNoRoles), fleet.ListOptions{})
-	require.NoError(t, err)
-	require.Len(t, activities, 2)
+	// user with no roles cannot read activities
+	_, err := svc.ListActivities(test.UserContext(test.UserNoRoles), fleet.ListOptions{})
+	require.Error(t, err)
+	require.Contains(t, err.Error(), authz.ForbiddenErrorMessage)
 
 	// no user in context
 	_, err = svc.ListActivities(context.Background(), fleet.ListOptions{})

programming_language: Go
filename: endpoint_utils_test.go
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,6 @@ import (
 	"testing"
 	"time"
 
-	authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
 	"github.com/fleetdm/fleet/v4/server/fleet"
 	"github.com/fleetdm/fleet/v4/server/mock"
 	"github.com/fleetdm/fleet/v4/server/ptr"
@@ -294,15 +293,11 @@ func TestEndpointer(t *testing.T) {
 
 	e := newUserAuthenticatedEndpointer(svc, fleetAPIOptions, r, "v1", "2021-11")
 	nopHandler := func(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) {
-		if authctx, ok := authz_ctx.FromContext(ctx); ok {
-			authctx.SetChecked()
-		}
+		setAuthCheckedOnPreAuthErr(ctx)
 		return "nop", nil
 	}
 	overrideHandler := func(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) {
-		if authctx, ok := authz_ctx.FromContext(ctx); ok {
-			authctx.SetChecked()
-		}
+		setAuthCheckedOnPreAuthErr(ctx)
 		return "override", nil
 	}
 

programming_language: Go
filename: global_schedule.go
change_type: ModificationType.MODIFY
diff: @@ -2,6 +2,7 @@ package service
 
 import (
 	"context"
+	"fmt"
 
 	"github.com/fleetdm/fleet/v4/server/fleet"
 	"github.com/fleetdm/fleet/v4/server/ptr"
@@ -36,7 +37,9 @@ func getGlobalScheduleEndpoint(ctx context.Context, request interface{}, svc fle
 }
 
 func (svc *Service) GetGlobalScheduledQueries(ctx context.Context, opts fleet.ListOptions) ([]*fleet.ScheduledQuery, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.Pack{}, fleet.ActionRead); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.Pack{
+		Type: ptr.String("global"),
+	}, fleet.ActionRead); err != nil {
 		return nil, err
 	}
 
@@ -88,7 +91,9 @@ func globalScheduleQueryEndpoint(ctx context.Context, request interface{}, svc f
 }
 
 func (svc *Service) GlobalScheduleQuery(ctx context.Context, sq *fleet.ScheduledQuery) (*fleet.ScheduledQuery, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.Pack{}, fleet.ActionRead); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.Pack{
+		Type: ptr.String("global"),
+	}, fleet.ActionRead); err != nil {
 		return nil, err
 	}
 
@@ -131,7 +136,9 @@ func modifyGlobalScheduleEndpoint(ctx context.Context, request interface{}, svc
 }
 
 func (svc *Service) ModifyGlobalScheduledQueries(ctx context.Context, id uint, query fleet.ScheduledQueryPayload) (*fleet.ScheduledQuery, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.Pack{}, fleet.ActionWrite); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.Pack{
+		Type: ptr.String("global"),
+	}, fleet.ActionWrite); err != nil {
 		return nil, err
 	}
 
@@ -170,9 +177,23 @@ func deleteGlobalScheduleEndpoint(ctx context.Context, request interface{}, svc
 }
 
 func (svc *Service) DeleteGlobalScheduledQueries(ctx context.Context, id uint) error {
-	if err := svc.authz.Authorize(ctx, &fleet.Pack{}, fleet.ActionWrite); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.Pack{
+		Type: ptr.String("global"),
+	}, fleet.ActionWrite); err != nil {
 		return err
 	}
 
+	globalPack, err := svc.ds.EnsureGlobalPack(ctx)
+	if err != nil {
+		return err
+	}
+	scheduledQuery, err := svc.ds.ScheduledQuery(ctx, id)
+	if err != nil {
+		return err
+	}
+	if scheduledQuery.PackID != globalPack.ID {
+		return fmt.Errorf("scheduled query %d is not global", id)
+	}
+
 	return svc.DeleteScheduledQuery(ctx, id)
 }

programming_language: Go
filename: global_schedule_test.go
change_type: ModificationType.MODIFY
diff: @@ -40,40 +40,40 @@ func TestGlobalScheduleAuth(t *testing.T) {
 		shouldFailRead  bool
 	}{
 		{
-			"global admin",
-			&fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
-			false,
-			false,
+			name:            "global admin",
+			user:            &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
+			shouldFailWrite: false,
+			shouldFailRead:  false,
 		},
 		{
-			"global maintainer",
-			&fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
-			false,
-			false,
+			name:            "global maintainer",
+			user:            &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
+			shouldFailWrite: false,
+			shouldFailRead:  false,
 		},
 		{
-			"global observer",
-			&fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
-			true,
-			true,
+			name:            "global observer",
+			user:            &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
+			shouldFailWrite: true,
+			shouldFailRead:  false,
 		},
 		{
-			"team admin",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
-			true,
-			false,
+			name:            "team admin",
+			user:            &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
+			shouldFailWrite: true,
+			shouldFailRead:  false,
 		},
 		{
-			"team maintainer",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
-			true,
-			false,
+			name:            "team maintainer",
+			user:            &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
+			shouldFailWrite: true,
+			shouldFailRead:  false,
 		},
 		{
-			"team observer",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
-			true,
-			true,
+			name:            "team observer",
+			user:            &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
+			shouldFailWrite: true,
+			shouldFailRead:  false,
 		},
 	}
 	for _, tt := range testCases {

programming_language: Go
filename: hosts.go
change_type: ModificationType.MODIFY
diff: @@ -7,7 +7,6 @@ import (
 	"time"
 
 	"github.com/fleetdm/fleet/v4/server/contexts/authz"
-	authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
 	"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
 	"github.com/fleetdm/fleet/v4/server/contexts/logging"
 	"github.com/fleetdm/fleet/v4/server/contexts/viewer"
@@ -275,7 +274,7 @@ func getHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service
 }
 
 func (svc *Service) GetHost(ctx context.Context, id uint) (*fleet.HostDetail, error) {
-	alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken)
+	alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken)
 	if !alreadyAuthd {
 		// First ensure the user has access to list hosts, then check the specific
 		// host once team_id is loaded.
@@ -560,7 +559,7 @@ func refetchHostEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
 }
 
 func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
-	if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) {
+	if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) {
 		if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
 			return err
 		}
@@ -676,7 +675,7 @@ func listHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc
 }
 
 func (svc *Service) ListHostDeviceMapping(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) {
-	if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) {
+	if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) {
 		if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
 			return nil, err
 		}
@@ -720,7 +719,7 @@ func getMacadminsDataEndpoint(ctx context.Context, request interface{}, svc flee
 }
 
 func (svc *Service) MacadminsData(ctx context.Context, id uint) (*fleet.MacadminsData, error) {
-	if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) {
+	if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) {
 		if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
 			return nil, err
 		}
@@ -932,5 +931,4 @@ func (svc *Service) OSVersions(ctx context.Context, teamID *uint, platform *stri
 	}
 
 	return osVersions, nil
-
 }

programming_language: Go
filename: scheduled_queries_test.go
change_type: ModificationType.MODIFY
diff: @@ -43,40 +43,43 @@ func TestScheduledQueriesAuth(t *testing.T) {
 		shouldFailRead  bool
 	}{
 		{
-			"global admin",
-			&fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
-			false,
-			false,
+			name:            "global admin",
+			user:            &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
+			shouldFailWrite: false,
+			shouldFailRead:  false,
 		},
 		{
-			"global maintainer",
-			&fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
-			false,
-			false,
+			name:            "global maintainer",
+			user:            &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
+			shouldFailWrite: false,
+			shouldFailRead:  false,
 		},
 		{
-			"global observer",
-			&fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
-			true,
-			true,
+			name:            "global observer",
+			user:            &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
+			shouldFailWrite: true,
+			shouldFailRead:  true,
 		},
+		// Team users cannot read or write scheduled queries using the below service APIs.
+		// Team users must use the "Team" endpoints (GetTeamScheduledQueries, TeamScheduleQuery,
+		// ModifyTeamScheduledQueries and DeleteTeamScheduledQueries).
 		{
-			"team admin",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
-			true,
-			false,
+			name:            "team admin",
+			user:            &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
+			shouldFailWrite: true,
+			shouldFailRead:  true,
 		},
 		{
-			"team maintainer",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
-			true,
-			false,
+			name:            "team maintainer",
+			user:            &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
+			shouldFailWrite: true,
+			shouldFailRead:  true,
 		},
 		{
-			"team observer",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
-			true,
-			true,
+			name:            "team observer",
+			user:            &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
+			shouldFailWrite: true,
+			shouldFailRead:  true,
 		},
 	}
 	for _, tt := range testCases {

programming_language: Go
filename: software.go
change_type: ModificationType.MODIFY
diff: @@ -44,7 +44,9 @@ func listSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet.Se
 }
 
 func (svc Service) ListSoftware(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.Software{}, fleet.ActionRead); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{
+		TeamID: opt.TeamID,
+	}, fleet.ActionRead); err != nil {
 		return nil, err
 	}
 
@@ -82,7 +84,9 @@ func countSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet.S
 }
 
 func (svc Service) CountSoftware(ctx context.Context, opt fleet.SoftwareListOptions) (int, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.Software{}, fleet.ActionRead); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{
+		TeamID: opt.TeamID,
+	}, fleet.ActionRead); err != nil {
 		return 0, err
 	}
 

programming_language: Go
filename: software_test.go
change_type: ModificationType.MODIFY
diff: @@ -23,7 +23,11 @@ func TestService_ListSoftware(t *testing.T) {
 		return []fleet.Software{}, nil
 	}
 
-	user := &fleet.User{ID: 3, Email: "foo@bar.com", GlobalRole: ptr.String(fleet.RoleObserver)}
+	user := &fleet.User{
+		ID:         3,
+		Email:      "foo@bar.com",
+		GlobalRole: ptr.String(fleet.RoleAdmin),
+	}
 
 	svc := newTestService(t, ds, nil, nil)
 	ctx := context.Background()
@@ -48,3 +52,146 @@ func TestService_ListSoftware(t *testing.T) {
 	assert.Equal(t, fleet.ListOptions{PerPage: 11, Page: 2, OrderKey: "id", OrderDirection: fleet.OrderAscending}, calledWithOpt.ListOptions)
 	assert.True(t, calledWithOpt.WithHostCounts)
 }
+
+func TestServiceSoftwareInventoryAuth(t *testing.T) {
+	ds := new(mock.Store)
+
+	ds.ListSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, error) {
+		return []fleet.Software{}, nil
+	}
+	ds.CountSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) (int, error) {
+		return 0, nil
+	}
+	svc := newTestService(t, ds, nil, nil)
+
+	for _, tc := range []struct {
+		name                 string
+		user                 *fleet.User
+		shouldFailGlobalRead bool
+		shouldFailTeamRead   bool
+	}{
+		{
+			name: "global-admin",
+			user: &fleet.User{
+				ID:         1,
+				GlobalRole: ptr.String(fleet.RoleAdmin),
+			},
+			shouldFailGlobalRead: false,
+			shouldFailTeamRead:   false,
+		},
+		{
+			name: "global-maintainer",
+			user: &fleet.User{
+				ID:         1,
+				GlobalRole: ptr.String(fleet.RoleMaintainer),
+			},
+			shouldFailGlobalRead: false,
+			shouldFailTeamRead:   false,
+		},
+		{
+			name: "global-observer",
+			user: &fleet.User{
+				ID:         1,
+				GlobalRole: ptr.String(fleet.RoleObserver),
+			},
+			shouldFailGlobalRead: false,
+			shouldFailTeamRead:   false,
+		},
+		{
+			name: "team-admin-belongs-to-team",
+			user: &fleet.User{
+				ID: 1,
+				Teams: []fleet.UserTeam{{
+					Team: fleet.Team{ID: 1},
+					Role: fleet.RoleAdmin,
+				}},
+			},
+			shouldFailGlobalRead: true,
+			shouldFailTeamRead:   false,
+		},
+		{
+			name: "team-maintainer-belongs-to-team",
+			user: &fleet.User{
+				ID: 1,
+				Teams: []fleet.UserTeam{{
+					Team: fleet.Team{ID: 1},
+					Role: fleet.RoleMaintainer,
+				}},
+			},
+			shouldFailGlobalRead: true,
+			shouldFailTeamRead:   false,
+		},
+		{
+			name: "team-observer-belongs-to-team",
+			user: &fleet.User{
+				ID: 1,
+				Teams: []fleet.UserTeam{{
+					Team: fleet.Team{ID: 1},
+					Role: fleet.RoleObserver,
+				}},
+			},
+			shouldFailGlobalRead: true,
+			shouldFailTeamRead:   false,
+		},
+		{
+			name: "team-admin-does-not-belong-to-team",
+			user: &fleet.User{
+				ID: 1,
+				Teams: []fleet.UserTeam{{
+					Team: fleet.Team{ID: 2},
+					Role: fleet.RoleAdmin,
+				}},
+			},
+			shouldFailGlobalRead: true,
+			shouldFailTeamRead:   true,
+		},
+		{
+			name: "team-maintainer-does-not-belong-to-team",
+			user: &fleet.User{
+				ID: 1,
+				Teams: []fleet.UserTeam{{
+					Team: fleet.Team{ID: 2},
+					Role: fleet.RoleMaintainer,
+				}},
+			},
+			shouldFailGlobalRead: true,
+			shouldFailTeamRead:   true,
+		},
+		{
+			name: "team-observer-does-not-belong-to-team",
+			user: &fleet.User{
+				ID: 1,
+				Teams: []fleet.UserTeam{{
+					Team: fleet.Team{ID: 2},
+					Role: fleet.RoleObserver,
+				}},
+			},
+			shouldFailGlobalRead: true,
+			shouldFailTeamRead:   true,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tc.user})
+
+			// List all software.
+			_, err := svc.ListSoftware(ctx, fleet.SoftwareListOptions{})
+			checkAuthErr(t, tc.shouldFailGlobalRead, err)
+
+			// Count all software.
+			_, err = svc.CountSoftware(ctx, fleet.SoftwareListOptions{})
+			checkAuthErr(t, tc.shouldFailGlobalRead, err)
+
+			// List software for a team.
+			_, err = svc.ListSoftware(ctx, fleet.SoftwareListOptions{
+				TeamID: ptr.Uint(1),
+			})
+			checkAuthErr(t, tc.shouldFailTeamRead, err)
+
+			// Count software for a team.
+			_, err = svc.CountSoftware(ctx, fleet.SoftwareListOptions{
+				TeamID: ptr.Uint(1),
+			})
+			checkAuthErr(t, tc.shouldFailTeamRead, err)
+		})
+	}
+}

programming_language: Go
filename: team_schedule.go
change_type: ModificationType.MODIFY
diff: @@ -2,6 +2,7 @@ package service
 
 import (
 	"context"
+	"fmt"
 
 	"github.com/fleetdm/fleet/v4/server/fleet"
 	"github.com/fleetdm/fleet/v4/server/ptr"
@@ -36,7 +37,9 @@ func getTeamScheduleEndpoint(ctx context.Context, request interface{}, svc fleet
 }
 
 func (svc Service) GetTeamScheduledQueries(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]*fleet.ScheduledQuery, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.Pack{TeamIDs: []uint{teamID}}, fleet.ActionRead); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.Pack{
+		Type: ptr.String(fmt.Sprintf("team-%d", teamID)),
+	}, fleet.ActionRead); err != nil {
 		return nil, err
 	}
 
@@ -92,14 +95,15 @@ func teamScheduleQueryEndpoint(ctx context.Context, request interface{}, svc fle
 	if err != nil {
 		return teamScheduleQueryResponse{Err: err}, nil
 	}
-	_ = resp
 	return teamScheduleQueryResponse{
 		Scheduled: resp,
 	}, nil
 }
 
 func (svc Service) TeamScheduleQuery(ctx context.Context, teamID uint, q *fleet.ScheduledQuery) (*fleet.ScheduledQuery, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.Pack{TeamIDs: []uint{teamID}}, fleet.ActionWrite); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.Pack{
+		Type: ptr.String(fmt.Sprintf("team-%d", teamID)),
+	}, fleet.ActionWrite); err != nil {
 		return nil, err
 	}
 
@@ -140,7 +144,9 @@ func modifyTeamScheduleEndpoint(ctx context.Context, request interface{}, svc fl
 }
 
 func (svc Service) ModifyTeamScheduledQueries(ctx context.Context, teamID uint, scheduledQueryID uint, query fleet.ScheduledQueryPayload) (*fleet.ScheduledQuery, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.Pack{TeamIDs: []uint{teamID}}, fleet.ActionWrite); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.Pack{
+		Type: ptr.String(fmt.Sprintf("team-%d", teamID)),
+	}, fleet.ActionWrite); err != nil {
 		return nil, err
 	}
 
@@ -180,7 +186,9 @@ func deleteTeamScheduleEndpoint(ctx context.Context, request interface{}, svc fl
 }
 
 func (svc Service) DeleteTeamScheduledQueries(ctx context.Context, teamID uint, scheduledQueryID uint) error {
-	if err := svc.authz.Authorize(ctx, &fleet.Pack{TeamIDs: []uint{teamID}}, fleet.ActionWrite); err != nil {
+	if err := svc.authz.Authorize(ctx, &fleet.Pack{
+		Type: ptr.String(fmt.Sprintf("team-%d", teamID)),
+	}, fleet.ActionWrite); err != nil {
 		return err
 	}
 	return svc.ds.DeleteScheduledQuery(ctx, scheduledQueryID)

programming_language: Go
filename: team_schedule_test.go
change_type: ModificationType.MODIFY
diff: @@ -2,6 +2,7 @@ package service
 
 import (
 	"context"
+	"fmt"
 	"testing"
 
 	"github.com/fleetdm/fleet/v4/server/contexts/viewer"
@@ -15,7 +16,10 @@ func TestTeamScheduleAuth(t *testing.T) {
 	svc := newTestService(t, ds, nil, nil)
 
 	ds.EnsureTeamPackFunc = func(ctx context.Context, teamID uint) (*fleet.Pack, error) {
-		return &fleet.Pack{ID: 999}, nil
+		return &fleet.Pack{
+			ID:   999,
+			Type: ptr.String(fmt.Sprintf("team-%d", teamID)),
+		}, nil
 	}
 	ds.ListScheduledQueriesInPackWithStatsFunc = func(ctx context.Context, id uint, opts fleet.ListOptions) ([]*fleet.ScheduledQuery, error) {
 		return nil, nil
@@ -76,7 +80,7 @@ func TestTeamScheduleAuth(t *testing.T) {
 			"team observer, belongs to team",
 			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
 			true,
-			true,
+			false,
 		},
 		{
 			"team maintainer, DOES NOT belong to team",

programming_language: Go
filename: users.go
change_type: ModificationType.MODIFY
diff: @@ -11,6 +11,7 @@ import (
 
 	"github.com/fleetdm/fleet/v4/server"
 	"github.com/fleetdm/fleet/v4/server/authz"
+	authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
 	"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
 	"github.com/fleetdm/fleet/v4/server/contexts/viewer"
 	"github.com/fleetdm/fleet/v4/server/fleet"
@@ -140,7 +141,11 @@ func listUsersEndpoint(ctx context.Context, request interface{}, svc fleet.Servi
 }
 
 func (svc *Service) ListUsers(ctx context.Context, opt fleet.UserListOptions) ([]*fleet.User, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.User{}, fleet.ActionRead); err != nil {
+	user := &fleet.User{}
+	if opt.TeamID != 0 {
+		user.Teams = []fleet.UserTeam{{Team: fleet.Team{ID: opt.TeamID}}}
+	}
+	if err := svc.authz.Authorize(ctx, user, fleet.ActionRead); err != nil {
 		return nil, err
 	}
 
@@ -216,12 +221,27 @@ func getUserEndpoint(ctx context.Context, request interface{}, svc fleet.Service
 	return getUserResponse{User: user, AvailableTeams: availableTeams}, nil
 }
 
+// setAuthCheckedOnPreAuthErr can be used to set the authentication as checked
+// in case of errors that happened before an auth check can be performed.
+// Otherwise the endpoints return a "authentication skipped" error instead of
+// the actual returned error.
+func setAuthCheckedOnPreAuthErr(ctx context.Context) {
+	if az, ok := authz_ctx.FromContext(ctx); ok {
+		az.SetChecked()
+	}
+}
+
 func (svc *Service) User(ctx context.Context, id uint) (*fleet.User, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.User{ID: id}, fleet.ActionRead); err != nil {
-		return nil, err
+	user, err := svc.ds.UserByID(ctx, id)
+	if err != nil {
+		setAuthCheckedOnPreAuthErr(ctx)
+		return nil, ctxerr.Wrap(ctx, err)
 	}
 
-	return svc.ds.UserByID(ctx, id)
+	if err := svc.authz.Authorize(ctx, user, fleet.ActionRead); err != nil {
+		return nil, err
+	}
+	return user, nil
 }
 
 ////////////////////////////////////////////////////////////////////////////////
@@ -251,12 +271,9 @@ func modifyUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv
 }
 
 func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPayload) (*fleet.User, error) {
-	if err := svc.authz.Authorize(ctx, &fleet.User{}, fleet.ActionRead); err != nil {
-		return nil, err
-	}
-
 	user, err := svc.User(ctx, userID)
 	if err != nil {
+		setAuthCheckedOnPreAuthErr(ctx)
 		return nil, err
 	}
 
@@ -326,7 +343,10 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay
 
 	if p.GlobalRole != nil && *p.GlobalRole != "" {
 		if currentUser.GlobalRole == nil {
-			return nil, ctxerr.New(ctx, "Cannot edit global role as a team member")
+			return nil, authz.ForbiddenWithInternal(
+				"cannot edit global role as a team member",
+				currentUser, user, fleet.ActionWriteRole,
+			)
 		}
 
 		if p.Teams != nil && len(*p.Teams) > 0 {
@@ -336,7 +356,10 @@ func (svc *Service) ModifyUser(ctx context.Context, userID uint, p fleet.UserPay
 		user.Teams = []fleet.UserTeam{}
 	} else if p.Teams != nil {
 		if !isAdminOfTheModifiedTeams(currentUser, user.Teams, *p.Teams) {
-			return nil, ctxerr.New(ctx, "Cannot modify teams in that way")
+			return nil, authz.ForbiddenWithInternal(
+				"cannot modify teams in that way",
+				currentUser, user, fleet.ActionWriteRole,
+			)
 		}
 		user.Teams = *p.Teams
 		user.GlobalRole = nil
@@ -379,10 +402,14 @@ func deleteUserEndpoint(ctx context.Context, request interface{}, svc fleet.Serv
 }
 
 func (svc *Service) DeleteUser(ctx context.Context, id uint) error {
-	if err := svc.authz.Authorize(ctx, &fleet.User{ID: id}, fleet.ActionWrite); err != nil {
+	user, err := svc.ds.UserByID(ctx, id)
+	if err != nil {
+		setAuthCheckedOnPreAuthErr(ctx)
+		return ctxerr.Wrap(ctx, err)
+	}
+	if err := svc.authz.Authorize(ctx, user, fleet.ActionWrite); err != nil {
 		return err
 	}
-
 	return svc.ds.DeleteUser(ctx, id)
 }
 
@@ -616,27 +643,42 @@ func (svc *Service) ChangeUserEmail(ctx context.Context, token string) (string,
 	return svc.ds.ConfirmPendingEmailChange(ctx, vc.UserID(), token)
 }
 
+// isAdminOfTheModifiedTeams checks whether the current user is allowed to modify the user
+// roles in the teams.
+//
+// TODO: End-goal is to move all this logic to policy.rego.
 func isAdminOfTheModifiedTeams(currentUser *fleet.User, originalUserTeams, newUserTeams []fleet.UserTeam) bool {
-	// If the user is of the right global role, then they can modify the teams
-	if currentUser.GlobalRole != nil && (*currentUser.GlobalRole == fleet.RoleAdmin || *currentUser.GlobalRole == fleet.RoleMaintainer) {
+	// Global admins can modify all user teams roles.
+	if currentUser.GlobalRole != nil && *currentUser.GlobalRole == fleet.RoleAdmin {
 		return true
 	}
 
-	// otherwise, gather the resulting teams
-	resultingTeams := make(map[uint]string)
+	// Otherwise, make a map of the original and resulting teams.
+	newTeams := make(map[uint]string)
 	for _, team := range newUserTeams {
-		resultingTeams[team.ID] = team.Role
+		newTeams[team.ID] = team.Role
+	}
+	originalTeams := make(map[uint]struct{})
+	for _, team := range originalUserTeams {
+		originalTeams[team.ID] = struct{}{}
 	}
 
-	// and see which ones were removed or changed from the original
+	// See which ones were removed or changed from the original.
 	teamsAffected := make(map[uint]struct{})
 	for _, team := range originalUserTeams {
-		if resultingTeams[team.ID] != team.Role {
+		if newTeams[team.ID] != team.Role {
+			teamsAffected[team.ID] = struct{}{}
+		}
+	}
+
+	// See which ones of the new are not in the original.
+	for _, team := range newUserTeams {
+		if _, ok := originalTeams[team.ID]; !ok {
 			teamsAffected[team.ID] = struct{}{}
 		}
 	}
 
-	// then gather the teams the current user is admin for
+	// Then gather the teams the current user is admin for.
 	currentUserTeamAdmin := make(map[uint]struct{})
 	for _, team := range currentUser.Teams {
 		if team.Role == fleet.RoleAdmin {
@@ -644,7 +686,8 @@ func isAdminOfTheModifiedTeams(currentUser *fleet.User, originalUserTeams, newUs
 		}
 	}
 
-	// and let's check that the teams that were either removed or changed are also teams this user is an admin of
+	// And finally, let's check that the teams that were either removed
+	// or changed are also teams this user is an admin of.
 	for teamID := range teamsAffected {
 		if _, ok := currentUserTeamAdmin[teamID]; !ok {
 			return false

programming_language: Go
filename: users_test.go
change_type: ModificationType.MODIFY
diff: @@ -7,6 +7,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/fleetdm/fleet/v4/server/authz"
 	"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
 	"github.com/fleetdm/fleet/v4/server/contexts/viewer"
 	"github.com/fleetdm/fleet/v4/server/datastore/mysql"
@@ -41,18 +42,27 @@ func TestUserAuth(t *testing.T) {
 	ds.InviteByEmailFunc = func(ctx context.Context, email string) (*fleet.Invite, error) {
 		return nil, errors.New("AA")
 	}
+
+	userTeamMaintainerID := uint(999)
+	userGlobalMaintainerID := uint(888)
+	var self *fleet.User // to be set by tests
 	ds.UserByIDFunc = func(ctx context.Context, id uint) (*fleet.User, error) {
-		if id == 999 {
+		switch id {
+		case userTeamMaintainerID:
 			return &fleet.User{
-				ID:    999,
+				ID:    userTeamMaintainerID,
 				Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}},
 			}, nil
+		case userGlobalMaintainerID:
+			return &fleet.User{
+				ID:         userGlobalMaintainerID,
+				GlobalRole: ptr.String(fleet.RoleMaintainer),
+			}, nil
+		default:
+			return self, nil
 		}
-		return &fleet.User{
-			ID:         888,
-			GlobalRole: ptr.String(fleet.RoleMaintainer),
-		}, nil
 	}
+
 	ds.SaveUserFunc = func(ctx context.Context, user *fleet.User) error {
 		return nil
 	}
@@ -70,92 +80,277 @@ func TestUserAuth(t *testing.T) {
 	}
 
 	testCases := []struct {
-		name                  string
-		user                  *fleet.User
+		name string
+		user *fleet.User
+
 		shouldFailGlobalWrite bool
 		shouldFailTeamWrite   bool
-		shouldFailRead        bool
-		shouldFailDeleteReset bool
+
+		shouldFailWriteRoleGlobalToGlobal    bool
+		shouldFailWriteRoleGlobalToTeam      bool
+		shouldFailWriteRoleTeamToAnotherTeam bool
+		shouldFailWriteRoleTeamToGlobal      bool
+
+		shouldFailWriteRoleOwnDomain bool
+
+		shouldFailGlobalRead bool
+		shouldFailTeamRead   bool
+
+		shouldFailGlobalDelete bool
+		shouldFailTeamDelete   bool
+
+		shouldFailGlobalPasswordReset bool
+		shouldFailTeamPasswordReset   bool
+
+		shouldFailGlobalChangePassword bool
+		shouldFailTeamChangePassword   bool
+
+		shouldFailListAll  bool
+		shouldFailListTeam bool
 	}{
 		{
-			"global admin",
-			&fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
-			false,
-			false,
-			false,
-			false,
+			name:                                 "global admin",
+			user:                                 &fleet.User{ID: 1000, GlobalRole: ptr.String(fleet.RoleAdmin)},
+			shouldFailGlobalWrite:                false,
+			shouldFailTeamWrite:                  false,
+			shouldFailWriteRoleGlobalToGlobal:    false,
+			shouldFailWriteRoleGlobalToTeam:      false,
+			shouldFailWriteRoleTeamToAnotherTeam: false,
+			shouldFailWriteRoleTeamToGlobal:      false,
+			shouldFailWriteRoleOwnDomain:         false,
+			shouldFailGlobalRead:                 false,
+			shouldFailTeamRead:                   false,
+			shouldFailGlobalDelete:               false,
+			shouldFailTeamDelete:                 false,
+			shouldFailGlobalPasswordReset:        false,
+			shouldFailTeamPasswordReset:          false,
+			shouldFailGlobalChangePassword:       false,
+			shouldFailTeamChangePassword:         false,
+			shouldFailListAll:                    false,
+			shouldFailListTeam:                   false,
 		},
 		{
-			"global maintainer",
-			&fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
-			true,
-			true,
-			false,
-			true,
+			name:                                 "global maintainer",
+			user:                                 &fleet.User{ID: 1000, GlobalRole: ptr.String(fleet.RoleMaintainer)},
+			shouldFailGlobalWrite:                true,
+			shouldFailTeamWrite:                  true,
+			shouldFailWriteRoleGlobalToGlobal:    true,
+			shouldFailWriteRoleGlobalToTeam:      true,
+			shouldFailWriteRoleTeamToAnotherTeam: true,
+			shouldFailWriteRoleTeamToGlobal:      true,
+			shouldFailWriteRoleOwnDomain:         true,
+			shouldFailGlobalRead:                 true,
+			shouldFailTeamRead:                   true,
+			shouldFailGlobalDelete:               true,
+			shouldFailTeamDelete:                 true,
+			shouldFailGlobalPasswordReset:        true,
+			shouldFailTeamPasswordReset:          true,
+			shouldFailGlobalChangePassword:       true,
+			shouldFailTeamChangePassword:         true,
+			shouldFailListAll:                    true,
+			shouldFailListTeam:                   true,
 		},
 		{
-			"global observer",
-			&fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)},
-			true,
-			true,
-			false,
-			true,
+			name:                                 "global observer",
+			user:                                 &fleet.User{ID: 1000, GlobalRole: ptr.String(fleet.RoleObserver)},
+			shouldFailGlobalWrite:                true,
+			shouldFailTeamWrite:                  true,
+			shouldFailWriteRoleGlobalToGlobal:    true,
+			shouldFailWriteRoleGlobalToTeam:      true,
+			shouldFailWriteRoleTeamToAnotherTeam: true,
+			shouldFailWriteRoleTeamToGlobal:      true,
+			shouldFailWriteRoleOwnDomain:         true,
+			shouldFailGlobalRead:                 true,
+			shouldFailTeamRead:                   true,
+			shouldFailGlobalDelete:               true,
+			shouldFailTeamDelete:                 true,
+			shouldFailGlobalPasswordReset:        true,
+			shouldFailTeamPasswordReset:          true,
+			shouldFailGlobalChangePassword:       true,
+			shouldFailTeamChangePassword:         true,
+			shouldFailListAll:                    true,
+			shouldFailListTeam:                   true,
 		},
 		{
-			"team admin, belongs to team",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
-			true,
-			false,
-			false,
-			true,
+			name:                                 "team admin, belongs to team",
+			user:                                 &fleet.User{ID: 1000, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}},
+			shouldFailGlobalWrite:                true,
+			shouldFailTeamWrite:                  false,
+			shouldFailWriteRoleGlobalToGlobal:    true,
+			shouldFailWriteRoleGlobalToTeam:      true,
+			shouldFailWriteRoleTeamToAnotherTeam: true,
+			shouldFailWriteRoleTeamToGlobal:      true,
+			shouldFailWriteRoleOwnDomain:         false,
+			shouldFailGlobalRead:                 true,
+			shouldFailTeamRead:                   false,
+			shouldFailGlobalDelete:               true,
+			shouldFailTeamDelete:                 false,
+			shouldFailGlobalPasswordReset:        true,
+			shouldFailTeamPasswordReset:          true,
+			shouldFailGlobalChangePassword:       true,
+			shouldFailTeamChangePassword:         true,
+			shouldFailListAll:                    true,
+			shouldFailListTeam:                   false,
 		},
 		{
-			"team maintainer, belongs to team",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
-			true,
-			true,
-			false,
-			true,
+			name:                                 "team maintainer, belongs to team",
+			user:                                 &fleet.User{ID: 1000, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}},
+			shouldFailGlobalWrite:                true,
+			shouldFailTeamWrite:                  true,
+			shouldFailWriteRoleGlobalToGlobal:    true,
+			shouldFailWriteRoleGlobalToTeam:      true,
+			shouldFailWriteRoleTeamToAnotherTeam: true,
+			shouldFailWriteRoleTeamToGlobal:      true,
+			shouldFailWriteRoleOwnDomain:         true,
+			shouldFailGlobalRead:                 true,
+			shouldFailTeamRead:                   true,
+			shouldFailGlobalDelete:               true,
+			shouldFailTeamDelete:                 true,
+			shouldFailGlobalPasswordReset:        true,
+			shouldFailTeamPasswordReset:          true,
+			shouldFailGlobalChangePassword:       true,
+			shouldFailTeamChangePassword:         true,
+			shouldFailListAll:                    true,
+			shouldFailListTeam:                   true,
 		},
 		{
-			"team observer, belongs to team",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
-			true,
-			true,
-			false,
-			true,
+			name:                                 "team observer, belongs to team",
+			user:                                 &fleet.User{ID: 1000, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}},
+			shouldFailGlobalWrite:                true,
+			shouldFailTeamWrite:                  true,
+			shouldFailWriteRoleGlobalToGlobal:    true,
+			shouldFailWriteRoleGlobalToTeam:      true,
+			shouldFailWriteRoleTeamToAnotherTeam: true,
+			shouldFailWriteRoleTeamToGlobal:      true,
+			shouldFailWriteRoleOwnDomain:         true,
+			shouldFailGlobalRead:                 true,
+			shouldFailTeamRead:                   true,
+			shouldFailGlobalDelete:               true,
+			shouldFailTeamDelete:                 true,
+			shouldFailGlobalPasswordReset:        true,
+			shouldFailTeamPasswordReset:          true,
+			shouldFailGlobalChangePassword:       true,
+			shouldFailTeamChangePassword:         true,
+			shouldFailListAll:                    true,
+			shouldFailListTeam:                   true,
 		},
 		{
-			"team maintainer, DOES NOT belong to team",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
-			true,
-			true,
-			false,
-			true,
+			name:                                 "team maintainer, DOES NOT belong to team",
+			user:                                 &fleet.User{ID: 1000, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}},
+			shouldFailGlobalWrite:                true,
+			shouldFailTeamWrite:                  true,
+			shouldFailWriteRoleGlobalToGlobal:    true,
+			shouldFailWriteRoleGlobalToTeam:      true,
+			shouldFailWriteRoleTeamToAnotherTeam: true,
+			shouldFailWriteRoleTeamToGlobal:      true,
+			shouldFailWriteRoleOwnDomain:         true,
+			shouldFailGlobalRead:                 true,
+			shouldFailTeamRead:                   true,
+			shouldFailGlobalDelete:               true,
+			shouldFailTeamDelete:                 true,
+			shouldFailGlobalPasswordReset:        true,
+			shouldFailTeamPasswordReset:          true,
+			shouldFailGlobalChangePassword:       true,
+			shouldFailTeamChangePassword:         true,
+			shouldFailListAll:                    true,
+			shouldFailListTeam:                   true,
 		},
 		{
-			"team admin, DOES NOT belong to team",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
-			true,
-			true,
-			false,
-			true,
+			name:                                 "team admin, DOES NOT belong to team",
+			user:                                 &fleet.User{ID: 1000, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleAdmin}}},
+			shouldFailGlobalWrite:                true,
+			shouldFailTeamWrite:                  true,
+			shouldFailWriteRoleGlobalToGlobal:    true,
+			shouldFailWriteRoleGlobalToTeam:      true,
+			shouldFailWriteRoleTeamToAnotherTeam: true,
+			shouldFailWriteRoleTeamToGlobal:      true,
+			shouldFailWriteRoleOwnDomain:         false, // this is testing changing its own role in the team it belongs to.
+			shouldFailGlobalRead:                 true,
+			shouldFailTeamRead:                   true,
+			shouldFailGlobalDelete:               true,
+			shouldFailTeamDelete:                 true,
+			shouldFailGlobalPasswordReset:        true,
+			shouldFailTeamPasswordReset:          true,
+			shouldFailGlobalChangePassword:       true,
+			shouldFailTeamChangePassword:         true,
+			shouldFailListAll:                    true,
+			shouldFailListTeam:                   true,
 		},
 		{
-			"team observer, DOES NOT belong to team",
-			&fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
-			true,
-			true,
-			false,
-			true,
+			name:                                 "team observer, DOES NOT belong to team",
+			user:                                 &fleet.User{ID: 1000, Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleObserver}}},
+			shouldFailGlobalWrite:                true,
+			shouldFailTeamWrite:                  true,
+			shouldFailWriteRoleGlobalToGlobal:    true,
+			shouldFailWriteRoleGlobalToTeam:      true,
+			shouldFailWriteRoleTeamToAnotherTeam: true,
+			shouldFailWriteRoleTeamToGlobal:      true,
+			shouldFailWriteRoleOwnDomain:         true,
+			shouldFailGlobalRead:                 true,
+			shouldFailTeamRead:                   true,
+			shouldFailGlobalDelete:               true,
+			shouldFailTeamDelete:                 true,
+			shouldFailGlobalPasswordReset:        true,
+			shouldFailTeamPasswordReset:          true,
+			shouldFailGlobalChangePassword:       true,
+			shouldFailTeamChangePassword:         true,
+			shouldFailListAll:                    true,
+			shouldFailListTeam:                   true,
 		},
 	}
 	for _, tt := range testCases {
 		t.Run(tt.name, func(t *testing.T) {
 			ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tt.user})
 
+			tt.user.SetPassword("p4ssw0rd.", 10, 10)
+
+			// To test a user reading/modifying itself.
+			u := *tt.user
+			self = &u
+
+			// A user can always read itself (read rego action).
+			_, err := svc.User(ctx, tt.user.ID)
+			require.NoError(t, err)
+
+			// A user can always write itself (write rego action).
+			_, err = svc.ModifyUser(ctx, tt.user.ID, fleet.UserPayload{Name: ptr.String("Foo")})
+			require.NoError(t, err)
+
+			// A user can always change its own password (change_password rego action).
+			_, err = svc.ModifyUser(ctx, tt.user.ID, fleet.UserPayload{Password: ptr.String("p4ssw0rd."), NewPassword: ptr.String("p4ssw0rd.3")})
+			require.NoError(t, err)
+
+			changeRole := func(role string) string {
+				switch role {
+				case fleet.RoleMaintainer:
+					return fleet.RoleAdmin // promote
+				case fleet.RoleAdmin:
+					return fleet.RoleMaintainer // demote
+				case fleet.RoleObserver:
+					return fleet.RoleAdmin // promote
+				default:
+					t.Fatalf("unknown role: %s", role)
+					return ""
+				}
+			}
+
+			// Test a user modifying its own role within its domain (write_role rego action).
+			if tt.user.GlobalRole != nil {
+				_, err = svc.ModifyUser(ctx, tt.user.ID, fleet.UserPayload{GlobalRole: ptr.String(changeRole(*tt.user.GlobalRole))})
+				checkAuthErr(t, tt.shouldFailWriteRoleOwnDomain, err)
+			} else { // Team user
+				ownTeamDifferentRole := []fleet.UserTeam{
+					{
+						Team: fleet.Team{ID: tt.user.Teams[0].ID},
+						Role: changeRole(tt.user.Teams[0].Role),
+					},
+				}
+				_, err = svc.ModifyUser(ctx, tt.user.ID, fleet.UserPayload{Teams: &ownTeamDifferentRole})
+				checkAuthErr(t, tt.shouldFailWriteRoleOwnDomain, err)
+			}
+
 			teams := []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}
-			_, err := svc.CreateUser(ctx, fleet.UserPayload{
+			_, err = svc.CreateUser(ctx, fleet.UserPayload{
 				Name:     ptr.String("Some Name"),
 				Email:    ptr.String("some@email.com"),
 				Password: ptr.String("passw0rd."),
@@ -171,29 +366,54 @@ func TestUserAuth(t *testing.T) {
 			})
 			checkAuthErr(t, tt.shouldFailGlobalWrite, err)
 
-			_, err = svc.ModifyUser(ctx, 999, fleet.UserPayload{Teams: &teams})
+			_, err = svc.ModifyUser(ctx, userGlobalMaintainerID, fleet.UserPayload{Name: ptr.String("Foo")})
+			checkAuthErr(t, tt.shouldFailGlobalWrite, err)
+
+			_, err = svc.ModifyUser(ctx, userTeamMaintainerID, fleet.UserPayload{Name: ptr.String("Bar")})
 			checkAuthErr(t, tt.shouldFailTeamWrite, err)
 
-			_, err = svc.ModifyUser(ctx, 888, fleet.UserPayload{Teams: &teams})
-			checkAuthErr(t, tt.shouldFailGlobalWrite, err)
+			_, err = svc.ModifyUser(ctx, userGlobalMaintainerID, fleet.UserPayload{GlobalRole: ptr.String(fleet.RoleMaintainer)})
+			checkAuthErr(t, tt.shouldFailWriteRoleGlobalToGlobal, err)
 
-			_, err = svc.ModifyUser(ctx, 888, fleet.UserPayload{GlobalRole: ptr.String(fleet.RoleMaintainer)})
-			checkAuthErr(t, tt.shouldFailGlobalWrite, err)
+			_, err = svc.ModifyUser(ctx, userGlobalMaintainerID, fleet.UserPayload{Teams: &teams})
+			checkAuthErr(t, tt.shouldFailWriteRoleGlobalToTeam, err)
 
-			err = svc.DeleteUser(ctx, 999)
-			checkAuthErr(t, tt.shouldFailDeleteReset, err)
+			anotherTeams := []fleet.UserTeam{{Team: fleet.Team{ID: 2}, Role: fleet.RoleMaintainer}}
+			_, err = svc.ModifyUser(ctx, userTeamMaintainerID, fleet.UserPayload{Teams: &anotherTeams})
+			checkAuthErr(t, tt.shouldFailWriteRoleTeamToAnotherTeam, err)
 
-			_, err = svc.RequirePasswordReset(ctx, 999, false)
-			checkAuthErr(t, tt.shouldFailDeleteReset, err)
+			_, err = svc.ModifyUser(ctx, userTeamMaintainerID, fleet.UserPayload{GlobalRole: ptr.String(fleet.RoleMaintainer)})
+			checkAuthErr(t, tt.shouldFailWriteRoleTeamToGlobal, err)
 
-			_, err = svc.ListUsers(ctx, fleet.UserListOptions{})
-			checkAuthErr(t, tt.shouldFailRead, err)
+			_, err = svc.User(ctx, userGlobalMaintainerID)
+			checkAuthErr(t, tt.shouldFailGlobalRead, err)
+
+			_, err = svc.User(ctx, userTeamMaintainerID)
+			checkAuthErr(t, tt.shouldFailTeamRead, err)
+
+			err = svc.DeleteUser(ctx, userGlobalMaintainerID)
+			checkAuthErr(t, tt.shouldFailGlobalDelete, err)
+
+			err = svc.DeleteUser(ctx, userTeamMaintainerID)
+			checkAuthErr(t, tt.shouldFailTeamDelete, err)
+
+			_, err = svc.RequirePasswordReset(ctx, userGlobalMaintainerID, false)
+			checkAuthErr(t, tt.shouldFailGlobalPasswordReset, err)
 
-			_, err = svc.User(ctx, 999)
-			checkAuthErr(t, tt.shouldFailRead, err)
+			_, err = svc.RequirePasswordReset(ctx, userTeamMaintainerID, false)
+			checkAuthErr(t, tt.shouldFailTeamPasswordReset, err)
 
-			_, err = svc.User(ctx, 888)
-			checkAuthErr(t, tt.shouldFailRead, err)
+			_, err = svc.ModifyUser(ctx, userGlobalMaintainerID, fleet.UserPayload{NewPassword: ptr.String("passw0rd.2")})
+			checkAuthErr(t, tt.shouldFailGlobalChangePassword, err)
+
+			_, err = svc.ModifyUser(ctx, userTeamMaintainerID, fleet.UserPayload{NewPassword: ptr.String("passw0rd.2")})
+			checkAuthErr(t, tt.shouldFailTeamChangePassword, err)
+
+			_, err = svc.ListUsers(ctx, fleet.UserListOptions{})
+			checkAuthErr(t, tt.shouldFailListAll, err)
+
+			_, err = svc.ListUsers(ctx, fleet.UserListOptions{TeamID: 1})
+			checkAuthErr(t, tt.shouldFailListTeam, err)
 		})
 	}
 }
@@ -677,3 +897,406 @@ func TestAuthenticatedUser(t *testing.T) {
 	assert.Nil(t, err)
 	assert.Equal(t, user, admin1)
 }
+
+func TestIsAdminOfTheModifiedTeams(t *testing.T) {
+	type teamWithRole struct {
+		teamID uint
+		role   string
+	}
+	type roles struct {
+		global *string
+		teams  []teamWithRole
+	}
+	for _, tc := range []struct {
+		name string
+		// actionUserRoles are the roles of the user executing the role change action.
+		actionUserRoles roles
+		// targetUserOriginalTeams are the original teams the target user belongs to.
+		targetUserOriginalTeams []teamWithRole
+		// targetUserNewTeams are the new teams the target user will be added to.
+		targetUserNewTeams []teamWithRole
+
+		expected bool
+	}{
+		{
+			name: "global-admin-allmighty",
+			actionUserRoles: roles{
+				global: ptr.String(fleet.RoleAdmin),
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 2,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			expected: true,
+		},
+		{
+			name: "global-maintainer-cannot-modify-team-users",
+			actionUserRoles: roles{
+				global: ptr.String(fleet.RoleMaintainer),
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleMaintainer,
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "team-admin-of-original-and-new",
+			actionUserRoles: roles{
+				teams: []teamWithRole{
+					{
+						teamID: 1,
+						role:   fleet.RoleAdmin,
+					},
+					{
+						teamID: 2,
+						role:   fleet.RoleAdmin,
+					},
+				},
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 2,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			expected: true,
+		},
+		{
+			name: "team-admin-of-one-original-and-leave-other-team-unmodified",
+			actionUserRoles: roles{
+				teams: []teamWithRole{
+					{
+						teamID: 1,
+						role:   fleet.RoleMaintainer,
+					},
+					{
+						teamID: 2,
+						role:   fleet.RoleAdmin,
+					},
+				},
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleMaintainer,
+				},
+				{
+					teamID: 2,
+					role:   fleet.RoleMaintainer,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleMaintainer,
+				},
+				{
+					teamID: 2,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			expected: true,
+		},
+		{
+			name: "team-admin-of-original-only",
+			actionUserRoles: roles{
+				teams: []teamWithRole{
+					{
+						teamID: 1,
+						role:   fleet.RoleAdmin,
+					},
+					{
+						teamID: 2,
+						role:   fleet.RoleMaintainer,
+					},
+				},
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 2,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "team-admin-of-new-only",
+			actionUserRoles: roles{
+				teams: []teamWithRole{
+					{
+						teamID: 1,
+						role:   fleet.RoleObserver,
+					},
+					{
+						teamID: 2,
+						role:   fleet.RoleAdmin,
+					},
+				},
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 2,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "team-admin-but-new-another-team-observer",
+			actionUserRoles: roles{
+				teams: []teamWithRole{
+					{
+						teamID: 1,
+						role:   fleet.RoleAdmin,
+					},
+				},
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+				{
+					teamID: 2,
+					role:   fleet.RoleObserver,
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "team-admin-but-new-another-team-admin",
+			actionUserRoles: roles{
+				teams: []teamWithRole{
+					{
+						teamID: 1,
+						role:   fleet.RoleAdmin,
+					},
+				},
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+				{
+					teamID: 2,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "team-admin-but-original-another-team",
+			actionUserRoles: roles{
+				teams: []teamWithRole{
+					{
+						teamID: 1,
+						role:   fleet.RoleAdmin,
+					},
+				},
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 2,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "team-admin-but-change-role-another-team",
+			actionUserRoles: roles{
+				teams: []teamWithRole{
+					{
+						teamID: 1,
+						role:   fleet.RoleAdmin,
+					},
+				},
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+				{
+					teamID: 2,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+				{
+					teamID: 2,
+					role:   fleet.RoleMaintainer,
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "team-admin-of-one-original-only",
+			actionUserRoles: roles{
+				teams: []teamWithRole{
+					{
+						teamID: 1,
+						role:   fleet.RoleMaintainer,
+					},
+					{
+						teamID: 2,
+						role:   fleet.RoleAdmin,
+					},
+				},
+			},
+			targetUserOriginalTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleMaintainer,
+				},
+				{
+					teamID: 2,
+					role:   fleet.RoleMaintainer,
+				},
+			},
+			targetUserNewTeams: []teamWithRole{
+				{
+					teamID: 1,
+					role:   fleet.RoleAdmin,
+				},
+				{
+					teamID: 2,
+					role:   fleet.RoleAdmin,
+				},
+			},
+			expected: false,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			userTeamsFn := func(twr []teamWithRole) []fleet.UserTeam {
+				var userTeams []fleet.UserTeam
+				for _, ot := range twr {
+					userTeams = append(userTeams, fleet.UserTeam{
+						Team: fleet.Team{ID: ot.teamID},
+						Role: ot.role,
+					})
+				}
+				return userTeams
+			}
+
+			actionUserTeams := userTeamsFn(tc.actionUserRoles.teams)
+			originalUserTeams := userTeamsFn(tc.targetUserOriginalTeams)
+			newUserTeams := userTeamsFn(tc.targetUserNewTeams)
+
+			result := isAdminOfTheModifiedTeams(
+				&fleet.User{
+					GlobalRole: tc.actionUserRoles.global,
+					Teams:      actionUserTeams,
+				},
+				originalUserTeams,
+				newUserTeams,
+			)
+			require.Equal(t, tc.expected, result)
+		})
+	}
+}
+
+// TestAdminAddRoleOtherTeam is an explicit test to check that
+// that an admin cannot add itself to another team.
+func TestTeamAdminAddRoleOtherTeam(t *testing.T) {
+	ds := new(mock.Store)
+	svc := newTestService(t, ds, nil, nil)
+
+	// adminTeam2 is a team admin of team with ID=2.
+	adminTeam2 := &fleet.User{
+		ID: 1,
+		Teams: []fleet.UserTeam{
+			{
+				Team: fleet.Team{ID: 2},
+				Role: fleet.RoleAdmin,
+			},
+		},
+	}
+
+	ds.UserByIDFunc = func(ctx context.Context, id uint) (*fleet.User, error) {
+		if id != 1 {
+			return nil, &notFoundError{}
+		}
+		return adminTeam2, nil
+	}
+	ds.SaveUserFunc = func(ctx context.Context, user *fleet.User) error {
+		return nil
+	}
+
+	ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: adminTeam2})
+	adminTeam2.SetPassword("p4ssw0rd.", 10, 10)
+
+	// adminTeam2 tries to add itself to team with ID=3 as admin.
+	_, err := svc.ModifyUser(ctx, adminTeam2.ID, fleet.UserPayload{
+		Teams: &[]fleet.UserTeam{
+			{
+				Team: fleet.Team{ID: 2},
+				Role: fleet.RoleAdmin,
+			},
+			{
+				Team: fleet.Team{ID: 3},
+				Role: fleet.RoleAdmin,
+			},
+		},
+	})
+	require.Equal(t, (&authz.Forbidden{}).Error(), err.Error())
+	require.False(t, ds.SaveUserFuncInvoked)
+}

programming_language: Go