go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucicfg/starlark/stdlib/internal/luci/lib/acl.star (about)

     1  # Copyright 2018 The LUCI Authors.
     2  #
     3  # Licensed under the Apache License, Version 2.0 (the "License");
     4  # you may not use this file except in compliance with the License.
     5  # You may obtain a copy of the License at
     6  #
     7  #      http://www.apache.org/licenses/LICENSE-2.0
     8  #
     9  # Unless required by applicable law or agreed to in writing, software
    10  # distributed under the License is distributed on an "AS IS" BASIS,
    11  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  # See the License for the specific language governing permissions and
    13  # limitations under the License.
    14  
    15  """Helper library for defining LUCI ACLs."""
    16  
    17  load("@stdlib//internal/validate.star", "validate")
    18  
    19  # TODO(vadimsh): Add support for 'anonymous' when/if needed.
    20  
    21  # A constructor for acl.role structs.
    22  #
    23  # Such structs are seen through public API as predefined symbols, e.g.
    24  # acl.LOGDOG_READER. There's no way for an end-user to define a new role.
    25  #
    26  # Expected to be used as roles in acl.entry(role=...) definitions, and maybe
    27  # printed (when debugging).
    28  #
    29  # Fields:
    30  #   name: name of the role.
    31  #   project_level_only: True if the role can be set only in project(...) rule.
    32  #   groups_only: True if the role should be assigned only to groups, not users.
    33  _role_ctor = __native__.genstruct("acl.role")
    34  
    35  # A constructor for acl.entry structs.
    36  #
    37  # Such structs are created via public acl.entry(...) API. To make their
    38  # printable representation useful and not confusing to end users, their
    39  # structure somewhat resembles acl.entry(...) arguments list.
    40  #
    41  # They are not convenient though when generating configs. For that reason
    42  # there's another representation of ACLs: as a list of elementary
    43  # (role, principal) tuples, where principals can be of few different types
    44  # (e.g. groups or users). Internal API function 'normalize_acls' converts
    45  # from the user-friendly acl.entry representation to the generator-friendly
    46  # acl.elementary representation.
    47  #
    48  # Fields:
    49  #   roles: a list of acl.role in the entry, at least one.
    50  #   users: a list of user emails to apply roles to, may be empty.
    51  #   groups: a list of group names to apply roles to, may be empty.
    52  #   projects: a list of project names to apply roles to, may be empty.
    53  _entry_ctor = __native__.genstruct("acl.entry")
    54  
    55  # A constructor for acl.elementary structs.
    56  #
    57  # This is conceptually a sum type: (Role, User | Group | Project). For
    58  # convenience it is represented as a tuple where only one of 'user', 'group' or
    59  # 'project' is set.
    60  #
    61  # Fields:
    62  #   role: an acl.role, always set.
    63  #   user: an user email.
    64  #   group: a group name.
    65  #   project: a project name.
    66  _elementary_ctor = __native__.genstruct("acl.elementary")
    67  
    68  def _role(
    69          name,
    70          *,
    71          realms_role,
    72          project_level_only = False,
    73          groups_only = False):
    74      """Defines a role.
    75  
    76      Internal API. Only predefined roles are available publicly, see the bottom
    77      of this file.
    78  
    79      Args:
    80        name: string name of the role.
    81        realms_role: matching predefined Realms role.
    82        project_level_only: True if it can be used only in project(...) ACLs.
    83        groups_only: True if role supports only group-based ACL (not user-based).
    84  
    85      Returns:
    86        acl.role struct.
    87      """
    88      return _role_ctor(
    89          name = name,
    90          realms_role = realms_role,
    91          project_level_only = project_level_only,
    92          groups_only = groups_only,
    93      )
    94  
    95  def _entry(
    96          roles,
    97          *,
    98          groups = None,
    99          users = None,
   100          projects = None):
   101      """Returns a new ACL binding.
   102  
   103      It assign the given role (or roles) to given individuals, groups or LUCI
   104      projects.
   105  
   106      Lists of acl.entry structs are passed to `acls` fields of luci.project(...)
   107      and luci.bucket(...) rules.
   108  
   109      An empty ACL binding is allowed. It is ignored everywhere. Useful for things
   110      like:
   111  
   112      ```python
   113      luci.project(
   114          acls = [
   115              acl.entry(acl.PROJECT_CONFIGS_READER, groups = [
   116                  # TODO: members will be added later
   117              ])
   118          ]
   119      )
   120      ```
   121  
   122      Args:
   123        roles: a single role or a list of roles to assign. Required.
   124        groups: a single group name or a list of groups to assign the role to.
   125        users: a single user email or a list of emails to assign the role to.
   126        projects: a single LUCI project name or a list of project names to assign
   127          the role to.
   128  
   129      Returns:
   130        acl.entry object, should be treated as opaque.
   131      """
   132      if __native__.ctor(roles) == _role_ctor:
   133          roles = [roles]
   134      elif roles != None and type(roles) != "list":
   135          validate.struct("roles", roles, _role_ctor)
   136  
   137      if type(groups) == "string":
   138          groups = [groups]
   139      elif groups != None and type(groups) != "list":
   140          validate.string("groups", groups)
   141  
   142      if type(users) == "string":
   143          users = [users]
   144      elif users != None and type(users) != "list":
   145          validate.string("users", users)
   146  
   147      if type(projects) == "string":
   148          projects = [projects]
   149      elif projects != None and type(projects) != "list":
   150          validate.string("projects", projects)
   151  
   152      roles = validate.list("roles", roles, required = True)
   153      groups = validate.list("groups", groups)
   154      users = validate.list("users", users)
   155      projects = validate.list("projects", projects)
   156  
   157      for r in roles:
   158          validate.struct("roles", r, _role_ctor)
   159      for g in groups:
   160          validate.string("groups", g)
   161      for u in users:
   162          validate.string("users", u)
   163      for p in projects:
   164          validate.string("projects", p)
   165  
   166      # Some ACLs (e.g. LogDog) can be formulated only in terms of groups,
   167      # check this.
   168      for r in roles:
   169          if r.groups_only and (users or projects):
   170              fail("role %s can be assigned only to groups" % r.name)
   171  
   172      return _entry_ctor(
   173          roles = roles,
   174          groups = groups,
   175          users = users,
   176          projects = projects,
   177      )
   178  
   179  def _validate_acls(
   180          acls,
   181          *,
   182          project_level = False,
   183          allowed_roles = None):
   184      """Validates the given list of acl.entry structs.
   185  
   186      Checks that project level roles are set only on the project level.
   187  
   188      Args:
   189        acls: an iterable of acl.entry structs to validate, or None.
   190        project_level: True to accept project_level_only=True roles.
   191        allowed_roles: an optional whitelist of roles to accept.
   192  
   193      Returns:
   194        A list of validated acl.entry structs or [], never None.
   195      """
   196      acls = validate.list("acls", acls)
   197      for e in acls:
   198          validate.struct("acls", e, _entry_ctor)
   199          for r in e.roles:
   200              if r.project_level_only and not project_level:
   201                  fail('bad "acls": role %s can only be set at the project level' % r.name)
   202              if allowed_roles and r not in allowed_roles:
   203                  fail('bad "acls": role %s is not allowed in this context' % r.name)
   204      return acls
   205  
   206  def _normalize_acls(acls):
   207      """Expands, dedups and sorts ACLs from the given list of acl.entry structs.
   208  
   209      Expands plural 'roles', 'groups', 'users' and 'projects' fields in acl.entry
   210      into multiple acl.elementary structs: elementary pairs of (role, principal),
   211      where principal is either a user, a group or a project.
   212  
   213      Args:
   214        acls: an iterable of acl.entry structs to expand, assumed to be validated.
   215  
   216      Returns:
   217        A sorted deduped list of acl.elementary structs.
   218      """
   219      out = []
   220      for e in (acls or []):
   221          for r in e.roles:
   222              for u in e.users:
   223                  out.append(_elementary_ctor(role = r, user = u, group = None, project = None))
   224              for g in e.groups:
   225                  out.append(_elementary_ctor(role = r, user = None, group = g, project = None))
   226              for p in e.projects:
   227                  out.append(_elementary_ctor(role = r, user = None, group = None, project = p))
   228      return sorted(set(out), key = _sort_key)
   229  
   230  def _sort_key(e):
   231      """acl.elementary -> tuple to sort it by."""
   232      if e.user:
   233          order, ident = 0, e.user
   234      elif e.group:
   235          order, ident = 1, e.group
   236      elif e.project:
   237          order, ident = 2, e.project
   238      else:
   239          fail("impossible")
   240      return (e.role.name, order, ident)
   241  
   242  def _binding_dicts(acls):
   243      """Takes a list of validated acl.entry structs and returns a list of dicts.
   244  
   245      Each dict contains keyword arguments for a luci.binding(...) rule. Together
   246      they represent the same ACL entries as `acls`.
   247      """
   248      per_role = {}  # role -> {roles: [role], groups: [], users: [], projects: []}.
   249      for e in _normalize_acls(acls):
   250          role = e.role.realms_role
   251          if not role:
   252              continue
   253  
   254          binding = per_role.get(role)
   255          if not binding:
   256              binding = {
   257                  "roles": [role],
   258                  "groups": [],
   259                  "users": [],
   260                  "projects": [],
   261              }
   262              per_role[role] = binding
   263  
   264          # `e` is acl.elementary which is a "union" struct: one and only one field is
   265          # set.
   266          if e.user:
   267              binding["users"].append(e.user)
   268          elif e.group:
   269              binding["groups"].append(e.group)
   270          elif e.project:
   271              binding["projects"].append(e.project)
   272  
   273      return per_role.values()
   274  
   275  ################################################################################
   276  
   277  acl = struct(
   278      entry = _entry,
   279  
   280      # Note: the information in the comments is extracted by the documentation
   281      # generator. That's the reason there's a bit of repetition here.
   282  
   283      # Reading contents of project configs through LUCI Config API/UI.
   284      #
   285      # DocTags:
   286      #   project_level_only.
   287      PROJECT_CONFIGS_READER = _role(
   288          "PROJECT_CONFIGS_READER",
   289          realms_role = "role/configs.reader",
   290          project_level_only = True,
   291      ),
   292  
   293      # Reading logs under project's logdog prefix.
   294      #
   295      # DocTags:
   296      #   project_level_only, groups_only.
   297      LOGDOG_READER = _role(
   298          "LOGDOG_READER",
   299          realms_role = "role/logdog.reader",
   300          project_level_only = True,
   301          groups_only = True,
   302      ),
   303  
   304      # Writing logs under project's logdog prefix.
   305      #
   306      # DocTags:
   307      #   project_level_only, groups_only.
   308      LOGDOG_WRITER = _role(
   309          "LOGDOG_WRITER",
   310          realms_role = "role/logdog.writer",
   311          project_level_only = True,
   312          groups_only = True,
   313      ),
   314  
   315      # Fetching info about a build, searching for builds in a bucket.
   316      BUILDBUCKET_READER = _role(
   317          "BUILDBUCKET_READER",
   318          realms_role = "role/buildbucket.reader",
   319      ),
   320      # Same as `BUILDBUCKET_READER` + scheduling and canceling builds.
   321      BUILDBUCKET_TRIGGERER = _role(
   322          "BUILDBUCKET_TRIGGERER",
   323          realms_role = "role/buildbucket.triggerer",
   324      ),
   325      # Full access to the bucket (should be used rarely).
   326      BUILDBUCKET_OWNER = _role(
   327          "BUILDBUCKET_OWNER",
   328          realms_role = "role/buildbucket.owner",
   329      ),
   330  
   331      # Viewing Scheduler jobs, invocations and their debug logs.
   332      SCHEDULER_READER = _role(
   333          "SCHEDULER_READER",
   334          realms_role = "role/scheduler.reader",
   335      ),
   336      # Same as `SCHEDULER_READER` + ability to trigger jobs.
   337      SCHEDULER_TRIGGERER = _role(
   338          "SCHEDULER_TRIGGERER",
   339          realms_role = "role/scheduler.triggerer",
   340      ),
   341      # Full access to Scheduler jobs, including ability to abort them.
   342      SCHEDULER_OWNER = _role(
   343          "SCHEDULER_OWNER",
   344          realms_role = "role/scheduler.owner",
   345      ),
   346  
   347      # Committing approved CLs via CQ.
   348      #
   349      # DocTags:
   350      #  cq_role, groups_only.
   351      CQ_COMMITTER = _role(
   352          "CQ_COMMITTER",
   353          groups_only = True,
   354          realms_role = "role/cq.committer",
   355      ),
   356  
   357      # Executing presubmit tests for CLs via CQ.
   358      #
   359      # DocTags:
   360      #  cq_role, groups_only.
   361      CQ_DRY_RUNNER = _role(
   362          "CQ_DRY_RUNNER",
   363          groups_only = True,
   364          realms_role = "role/cq.dryRunner",
   365      ),
   366  
   367      # Having CV automatically run certain tryjobs (e.g. static analyzers) when
   368      # a member uploads a new patchset to a CL monitored by CV and the feature
   369      # is enabled.
   370      #
   371      # DocTags:
   372      #  cq_role, groups_only.
   373      CQ_NEW_PATCHSET_RUN_TRIGGERER = _role(
   374          "CQ_NEW_PATCHSET_RUN_TRIGGERER",
   375          groups_only = True,
   376          realms_role = None,
   377      ),
   378  )
   379  
   380  aclimpl = struct(
   381      validate_acls = _validate_acls,
   382      normalize_acls = _normalize_acls,
   383      binding_dicts = _binding_dicts,
   384  )