blob: 0ee1b858c623af9327cc82b4d107a40cc348fd3f [file] [log] [blame]
// Copyright 2022 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package model
import (
"fmt"
"sort"
"strings"
"google.golang.org/protobuf/proto"
"go.chromium.org/luci/common/data/sortby"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/server/auth/service/protocol"
)
// RealmsAPIVersion is the currently acceptable version of Realms API.
// See api_version in realms.proto.
const RealmsAPIVersion = 1
// conditionSet dedups identical conditions, maps them to integer indexes.
//
// Most often identical conditions appear from implicit root bindings that are
// similar across all projects.
//
// Assumes all incoming protocol.Condition are immutable and already
// normalized. Retains the order in which they were added.
type conditionSet struct {
// all contains the unique Conditions added, preserving the order in which
// they were first added.
all []*protocol.Condition
// indexMapping is the mapping of Condition keys to the Condition's index
// within all.
indexMapping map[string]uint32
}
// add attempts to add the given Condition to the set, skipping if an
// equivalent Condition already exists in the set.
func (cs *conditionSet) add(cond *protocol.Condition) error {
condKey, err := conditionKey(cond)
if err != nil {
return err
}
if _, ok := cs.indexMapping[condKey]; ok {
// duplicate condition; no need to add it.
return nil
}
// This is a new Condition which needs to be recorded.
cs.indexMapping[condKey] = uint32(len(cs.all))
cs.all = append(cs.all, cond)
return nil
}
// relabel calculates the indices of equivalent Conditions in all, given target
// Conditions. The target conditions are specified by providing a slice of
// Conditions and the target Conditions' indices within that slice.
func (cs *conditionSet) relabel(localConds []*protocol.Condition, localIndices []uint32) ([]uint32, error) {
maxIndex := uint32(len(localConds) - 1)
globalIndices := make([]uint32, 0, len(localIndices))
errs := []error{}
for _, localIndex := range localIndices {
if localIndex > maxIndex {
errs = append(errs, fmt.Errorf("index %d > max index %d",
localIndex, maxIndex))
continue
}
targetKey, err := conditionKey(localConds[localIndex])
if err != nil {
errs = append(errs, err)
continue
}
globalIndex, ok := cs.indexMapping[targetKey]
if !ok {
errs = append(errs, fmt.Errorf("unknown condition at index %d",
localIndex))
continue
}
globalIndices = append(globalIndices, globalIndex)
}
return globalIndices, errors.NewMultiError(errs...).AsError()
}
// conditionKey generates a key by serializing a protocol.Condition.
func conditionKey(cond *protocol.Condition) (string, error) {
key, err := proto.Marshal(cond)
if err != nil {
return "", errors.Annotate(err, "error generating key").Err()
}
return string(key), nil
}
// MergeRealms merges all the project realms into one realms definition, using
// the permissions in the given AuthRealmsGlobals as an authoritative source of
// valid permissions.
//
// If some realm uses a permission not in the list, it will be silently dropped
// from the bindings. This can potentially happen due to the asynchronous nature
// of realms config updates (e.g. a role change that deletes some permissions
// can be committed into the AuthDB before realms are reevaluated). Eventually,
// the state should converge to be 100% consistent.
//
// Returns a single Realms object, which represents all projects' realms if
// there were no issues when merging, otherwise nil and an annotated error.
//
// An error may occur when:
// * unmarshalling project realms;
// * merging conditions into a single set;
// * processing a single realm that doesn't have the project ID as a prefix;
// * relabelling condition indices relative to the new set of all conditions.
func MergeRealms(
realmsGlobals *AuthRealmsGlobals,
allAuthProjectRealms []*AuthProjectRealms,
useV1Perms bool) (*protocol.Realms, error) {
result := &protocol.Realms{
ApiVersion: RealmsAPIVersion,
}
var permissions []*protocol.Permission
if realmsGlobals != nil {
if useV1Perms {
// Use the permissions stored by the Python version of Auth Service.
permissions = make([]*protocol.Permission, len(realmsGlobals.Permissions))
for i, s := range realmsGlobals.Permissions {
tempProto := &protocol.Permission{}
err := proto.Unmarshal([]byte(s), tempProto)
if err != nil {
return nil, errors.Annotate(err, "error while unmarshalling stored permission proto").Err()
}
permissions[i] = tempProto
}
} else if realmsGlobals.PermissionsList != nil {
permissions = realmsGlobals.PermissionsList.GetPermissions()
}
}
result.Permissions = permissions
// Permission name -> its index in the merged realms' permissions.
globalPermIndex := make(map[string]int, len(permissions))
for i, p := range permissions {
globalPermIndex[p.GetName()] = i
}
projectIDs := make([]string, len(allAuthProjectRealms))
realmsByProject := make(map[string]*protocol.Realms, len(allAuthProjectRealms))
for i, authProjectRealms := range allAuthProjectRealms {
projRealms, err := authProjectRealms.RealmsToProto()
if err != nil {
return nil, errors.Annotate(err,
"error parsing Realms from AuthProjectRealms").Err()
}
projectIDs[i] = authProjectRealms.ID
realmsByProject[authProjectRealms.ID] = projRealms
}
sort.Strings(projectIDs)
// Make the set of Conditions across all projects.
condSet := &conditionSet{
all: []*protocol.Condition{},
indexMapping: make(map[string]uint32),
}
for _, projectID := range projectIDs {
for _, cond := range realmsByProject[projectID].Conditions {
if err := condSet.add(cond); err != nil {
return nil, errors.Annotate(err,
"error merging conditions").Err()
}
}
}
result.Conditions = condSet.all
for _, projectID := range projectIDs {
expectedPrefix := fmt.Sprintf("%s:", projectID)
projectRealms := realmsByProject[projectID]
// Map the permission index in the project realms to the
// permission index in the merged realms.
oldToNewPermIndex := make([]int, len(projectRealms.Permissions))
for oldIndex, perm := range projectRealms.Permissions {
newIndex, ok := globalPermIndex[perm.GetName()]
if !ok {
newIndex = -1
}
oldToNewPermIndex[oldIndex] = newIndex
}
// Visit all bindings in all realms.
for _, oldRealm := range projectRealms.Realms {
realmName := oldRealm.GetName()
if !strings.HasPrefix(realmName, expectedPrefix) {
return nil, fmt.Errorf(
"project '%s' has realm with incorrectly prefixed name '%s'",
projectID, realmName)
}
newBindings := []*protocol.Binding{}
for _, oldBinding := range oldRealm.Bindings {
permIndices := make([]uint32, 0, len(oldBinding.Permissions))
for _, oldIndex := range oldBinding.Permissions {
if newIndex := oldToNewPermIndex[oldIndex]; newIndex >= 0 {
permIndices = append(permIndices, uint32(newIndex))
}
}
if len(permIndices) > 0 {
condIndices, err := condSet.relabel(projectRealms.Conditions, oldBinding.Conditions)
if err != nil {
return nil, errors.Annotate(err,
"error relabelling conditions").Err()
}
// Permissions and Conditions in a protocol.Binding must be
// in ascending order. See
// go.chromium.org/luci/server/auth/service/protocol#Binding
sortIndices(permIndices)
sortIndices(condIndices)
newBinding := &protocol.Binding{
Permissions: permIndices,
Conditions: condIndices,
Principals: oldBinding.Principals,
}
newBindings = append(newBindings, newBinding)
}
}
// Bindings in a protocol.Realm must be in lexicographical order.
// See go.chromium.org/luci/server/auth/service/protocol#Realm
sortBindings(newBindings)
newRealm := &protocol.Realm{
Name: realmName,
Bindings: newBindings,
Data: oldRealm.GetData(),
}
result.Realms = append(result.Realms, newRealm)
}
}
return result, nil
}
// /////////////////////////////////////////////////////////////////////
// ///////////////////// Sorting helper functions //////////////////////
// /////////////////////////////////////////////////////////////////////
func min(x, y int) int {
if x < y {
return x
}
return y
}
func sliceCompare[T string | uint32](sliceA []T, sliceB []T) bool {
maxCommonIndex := min(len(sliceA), len(sliceB))
for idx := 0; idx < maxCommonIndex; idx++ {
if sliceA[idx] != sliceB[idx] {
return sliceA[idx] < sliceB[idx]
}
}
return len(sliceA) < len(sliceB)
}
func sortIndices(indices []uint32) {
sort.Slice(indices, func(i, j int) bool {
return indices[i] < indices[j]
})
}
func sortBindings(bindings []*protocol.Binding) {
// Sort by Permissions primarily. Fallback to comparing Conditions,
// then Principals.
sort.Slice(bindings, sortby.Chain{
func(i, j int) bool {
return sliceCompare(bindings[i].Permissions, bindings[j].Permissions)
},
func(i, j int) bool {
return sliceCompare(bindings[i].Conditions, bindings[j].Conditions)
},
func(i, j int) bool {
return sliceCompare(bindings[i].Principals, bindings[j].Principals)
},
}.Use)
}
///////////////////////////////////////////////////////////////////////