blob: df374cb6fa79a6fcf6490024f793e51f22fd19b1 [file] [log] [blame]
// Copyright 2016 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 authdb
import (
"context"
"io"
"net"
"github.com/golang/protobuf/proto"
"go.opentelemetry.io/otel/attribute"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/server/auth/authdb/internal/certs"
"go.chromium.org/luci/server/auth/authdb/internal/graph"
"go.chromium.org/luci/server/auth/authdb/internal/ipaddr"
"go.chromium.org/luci/server/auth/authdb/internal/oauthid"
"go.chromium.org/luci/server/auth/authdb/internal/realmset"
"go.chromium.org/luci/server/auth/authdb/internal/seccfg"
"go.chromium.org/luci/server/auth/internal/tracing"
"go.chromium.org/luci/server/auth/realms"
"go.chromium.org/luci/server/auth/service/protocol"
"go.chromium.org/luci/server/auth/signing"
)
// SnapshotDB implements DB using AuthDB proto message.
//
// Use NewSnapshotDB to create new instances. Don't touch public fields
// of existing instances.
//
// Zero value represents an empty AuthDB.
type SnapshotDB struct {
AuthServiceURL string // where it was fetched from
Rev int64 // its revision number
groups *graph.QueryableGraph // queryable representation of groups
realms *realmset.Realms // queryable representation of realms
clientIDs oauthid.Allowlist // set of allowed client IDs
allowlistedIPs ipaddr.Allowlist // set of named IP allowlist
securityCfg *seccfg.SecurityConfig // parsed SecurityConfig proto
tokenServiceURL string // URL of the token server as provided by Auth service
tokenServiceCerts certs.Bundle // cached public keys of the token server
}
var _ DB = &SnapshotDB{}
// Revision returns a revision of an auth DB or 0 if it can't be determined.
//
// It's just a small helper that casts db to *SnapshotDB and extracts the
// revision from there.
func Revision(db DB) int64 {
if snap, _ := db.(*SnapshotDB); snap != nil {
return snap.Rev
}
return 0
}
// SnapshotDBFromTextProto constructs SnapshotDB by loading it from a text proto
// with AuthDB message.
func SnapshotDBFromTextProto(r io.Reader) (*SnapshotDB, error) {
blob, err := io.ReadAll(r)
if err != nil {
return nil, errors.Annotate(err, "failed to read the file").Err()
}
msg := &protocol.AuthDB{}
if err := proto.UnmarshalText(string(blob), msg); err != nil {
return nil, errors.Annotate(err, "not a valid AuthDB text proto file").Err()
}
db, err := NewSnapshotDB(msg, "", 0, true)
if err != nil {
return nil, errors.Annotate(err, "failed to validate AuthDB").Err()
}
return db, nil
}
// NewSnapshotDB creates new instance of SnapshotDB.
//
// It does some preprocessing to speed up subsequent checks. Returns errors if
// it encounters inconsistencies.
//
// If 'validate' is false, skips some expensive validation steps, assuming they
// were performed before, when AuthDB was initially received.
func NewSnapshotDB(authDB *protocol.AuthDB, authServiceURL string, rev int64, validate bool) (*SnapshotDB, error) {
if validate {
if err := validateAuthDB(authDB); err != nil {
return nil, err
}
}
groups, err := graph.BuildQueryable(authDB.Groups)
if err != nil {
return nil, errors.Annotate(err, "failed to build groups graph").Err()
}
var realmSet *realmset.Realms
if authDB.Realms != nil {
realmSet, err = realmset.Build(authDB.Realms, groups, realms.RegisteredPermissions())
if err != nil {
return nil, errors.Annotate(err, "failed to prepare Realms DB").Err()
}
}
allowlistedIPs, err := ipaddr.NewAllowlist(authDB.IpWhitelists, authDB.IpWhitelistAssignments)
if err != nil {
return nil, errors.Annotate(err, "bad IP allowlist in AuthDB").Err()
}
securityCfg, err := seccfg.Parse(authDB.SecurityConfig)
if err != nil {
return nil, errors.Annotate(err, "bad SecurityConfig").Err()
}
return &SnapshotDB{
AuthServiceURL: authServiceURL,
Rev: rev,
groups: groups,
realms: realmSet,
clientIDs: oauthid.NewAllowlist(authDB.OauthClientId, authDB.OauthAdditionalClientIds),
allowlistedIPs: allowlistedIPs,
securityCfg: securityCfg,
tokenServiceURL: authDB.TokenServerUrl,
tokenServiceCerts: certs.Bundle{ServiceURL: authDB.TokenServerUrl},
}, nil
}
// IsAllowedOAuthClientID returns true if the given OAuth2 client ID can be used
// to authorize access from the given email.
func (db *SnapshotDB) IsAllowedOAuthClientID(_ context.Context, email, clientID string) (bool, error) {
return db.clientIDs.IsAllowedOAuthClientID(email, clientID), nil
}
// IsInternalService returns true if the given hostname belongs to a service
// that is a part of the current LUCI deployment.
//
// What hosts are internal is controlled by 'internal_service_regexp' setting
// in security.cfg in the Auth Service configs.
func (db *SnapshotDB) IsInternalService(ctx context.Context, hostname string) (bool, error) {
if db.securityCfg != nil {
return db.securityCfg.IsInternalService(hostname), nil
}
return false, nil
}
// IsMember returns true if the given identity belongs to any of the groups.
//
// Unknown groups are considered empty, but are logged as warnings.
// May return errors if underlying datastore has issues.
func (db *SnapshotDB) IsMember(ctx context.Context, id identity.Identity, groups []string) (ok bool, err error) {
_, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.IsMember",
attribute.StringSlice("cr.dev.groups", groups),
)
defer func() { tracing.End(span, err, attribute.Bool("cr.dev.outcome", ok)) }()
if db.groups == nil {
return false, nil
}
// TODO(vadimsh): Optimize multi-group case.
for _, gr := range groups {
switch db.groups.IsMember(id, gr) {
case graph.IdentIsMember:
return true, nil
case graph.GroupIsUnknown:
logging.Warningf(ctx, "Group %q is unknown in auth db snapshot %d", gr, db.Rev)
}
}
return false, nil
}
// CheckMembership returns groups from the given list the identity belongs to.
//
// Unlike IsMember, it doesn't stop on the first hit but continues evaluating
// all groups.
//
// Unknown groups are considered empty. The order of groups in the result may
// be different from the order in 'groups'.
//
// May return errors if underlying datastore has issues.
func (db *SnapshotDB) CheckMembership(ctx context.Context, id identity.Identity, groups []string) (out []string, err error) {
_, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.CheckMembership",
attribute.StringSlice("cr.dev.groups", groups),
)
defer func() { tracing.End(span, err, attribute.StringSlice("cr.dev.outcome", out)) }()
if db.groups == nil {
return
}
// TODO(vadimsh): Optimize multi-group case.
for _, gr := range groups {
switch db.groups.IsMember(id, gr) {
case graph.IdentIsMember:
out = append(out, gr)
case graph.GroupIsUnknown:
logging.Warningf(ctx, "Group %q is unknown in auth db snapshot %d", gr, db.Rev)
}
}
return
}
// HasPermission returns true if the identity has the given permission in the
// realm.
func (db *SnapshotDB) HasPermission(ctx context.Context, id identity.Identity, perm realms.Permission, realm string, attrs realms.Attrs) (ok bool, err error) {
otelAttrs := append(make([]attribute.KeyValue, 0, 2+len(attrs)),
attribute.String("cr.dev.permission", perm.Name()),
attribute.String("cr.dev.realm", realm),
)
for k, v := range attrs {
otelAttrs = append(otelAttrs, attribute.String("cr.dev.attr."+k, v))
}
ctx, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.HasPermission",
otelAttrs...,
)
defer func() { tracing.End(span, err, attribute.Bool("cr.dev.outcome", ok)) }()
// This may happen if the AuthDB proto has no Realms yet.
if db.realms == nil {
return false, errors.Reason("Realms API is not available").Err()
}
permIdx, ok := db.realms.PermissionIndex(perm)
if !ok {
logging.Warningf(ctx, "Checking permission %q not present in the AuthDB", perm)
return false, nil
}
// Verify such realm is defined in the DB or fallback to its @root.
if !db.realms.HasRealm(realm) {
if err := realms.ValidateRealmName(realm, realms.GlobalScope); err != nil {
return false, errors.Annotate(err, "when checking %q", perm).Err()
}
project, name := realms.Split(realm)
root := realms.Join(project, realms.RootRealm)
if realm == root {
logging.Warningf(ctx, "Checking %q in a non-existing root realm %q: denying", perm, realm)
return false, nil
}
if !db.realms.HasRealm(root) {
logging.Warningf(ctx, "Checking %q in a non-existing realm %q that doesn't have a root realm (no such project?): denying", perm, realm)
return false, nil
}
// Don't log @legacy => @root fallbacks, they are semi-expected.
if name != realms.LegacyRealm {
logging.Warningf(ctx, "Checking %q in a non-existing realm %q: falling back to the root realm %q", perm, realm, root)
}
realm = root
}
// Grab the list of bindings for this permission and check if any applies to
// the `id` based on its group memberships.
q := db.groups.MembershipsQueryCache(id)
return db.realms.Bindings(realm, permIdx).Check(ctx, &q, attrs), nil
}
// QueryRealms returns a list of realms where the identity has the given
// permission.
func (db *SnapshotDB) QueryRealms(ctx context.Context, id identity.Identity, perm realms.Permission, project string, attrs realms.Attrs) (out []string, err error) {
otelAttrs := append(make([]attribute.KeyValue, 0, 2+len(attrs)),
attribute.String("cr.dev.permission", perm.Name()),
attribute.String("cr.dev.project", project),
)
for k, v := range attrs {
otelAttrs = append(otelAttrs, attribute.String("cr.dev.attr."+k, v))
}
ctx, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.QueryRealms",
otelAttrs...,
)
// `out` list can be huge. Just report the number of realms.
defer func() { tracing.End(span, err, attribute.Int("cr.dev.outcome", len(out))) }()
if project != "" {
if err := realms.ValidateProjectName(project); err != nil {
return nil, err
}
}
// This may happen if the AuthDB proto has no Realms yet.
if db.realms == nil {
return nil, errors.Reason("Realms API is not available").Err()
}
permIdx, ok := db.realms.PermissionIndex(perm)
if !ok {
logging.Warningf(ctx, "Querying realms with permission %q not present in the AuthDB", perm)
return nil, nil
}
// Get the map project => all bindings for the given permission there. This
// returns `ok == false` if the permission was not flagged with
// UsedInQueryRealms.
permBindings, ok := db.realms.QueryBindings(permIdx)
if !ok {
return nil, errors.Reason("permission %s cannot be used in QueryRealms: it was not flagged with UsedInQueryRealms flag", perm).Err()
}
// For each potentially matching list of bindings, check if it really matches.
q := db.groups.MembershipsQueryCache(id)
visit := func(bindings []realmset.RealmBindings) {
for _, realmBindings := range bindings {
if realmBindings.Bindings.Check(ctx, &q, attrs) {
out = append(out, realmBindings.Realm)
}
}
}
if project != "" {
visit(permBindings[project])
} else {
for _, bindings := range permBindings {
visit(bindings)
}
}
return out, nil
}
// FilterKnownGroups filters the list of groups keeping only ones that exist.
//
// May return errors if underlying datastore has issues. If all groups are
// unknown, returns an empty list and no error.
func (db *SnapshotDB) FilterKnownGroups(ctx context.Context, groups []string) (known []string, err error) {
_, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.FilterKnownGroups",
attribute.Int("cr.dev.groups", len(groups)),
)
defer func() { tracing.End(span, err, attribute.Int("cr.dev.outcome", len(known))) }()
if db.groups == nil {
return nil, nil
}
known = make([]string, 0, len(groups))
for _, gr := range groups {
if _, ok := db.groups.GroupIndex(gr); ok {
known = append(known, gr)
}
}
return known, nil
}
// GetCertificates returns a bundle with certificates of a trusted signer.
//
// Currently only the Token Server is a trusted signer.
func (db *SnapshotDB) GetCertificates(ctx context.Context, signerID identity.Identity) (*signing.PublicCertificates, error) {
if db.tokenServiceURL == "" {
logging.Warningf(
ctx, "Delegation is not supported, the token server URL is not set by %s",
db.AuthServiceURL)
return nil, nil
}
switch tokenServerID, certs, err := db.tokenServiceCerts.GetCerts(ctx); {
case err != nil:
return nil, err
case signerID != tokenServerID:
return nil, nil // signerID is not trusted since it's not a token server
default:
return certs, nil
}
}
// GetAllowlistForIdentity returns name of the IP allowlist to use to check
// IP of requests from the given `ident`.
//
// It's used to restrict access for certain account to certain IP subnets.
//
// Returns ("", nil) if `ident` is not IP restricted.
func (db *SnapshotDB) GetAllowlistForIdentity(ctx context.Context, ident identity.Identity) (string, error) {
return db.allowlistedIPs.GetAllowlistForIdentity(ident), nil
}
// IsAllowedIP returns true if IP address belongs to given named IP allowlist.
func (db *SnapshotDB) IsAllowedIP(ctx context.Context, ip net.IP, allowlist string) (bool, error) {
return db.allowlistedIPs.IsAllowedIP(ip, allowlist), nil
}
// GetAuthServiceURL returns root URL ("https://<host>") of the auth service
// the snapshot was fetched from.
//
// This is needed to implement authdb.DB interface.
func (db *SnapshotDB) GetAuthServiceURL(ctx context.Context) (string, error) {
if db.AuthServiceURL == "" {
return "", errors.Reason("not using Auth Service").Err()
}
return db.AuthServiceURL, nil
}
// GetTokenServiceURL returns root URL ("https://<host>") of the token server.
//
// This is needed to implement authdb.DB interface.
func (db *SnapshotDB) GetTokenServiceURL(ctx context.Context) (string, error) {
return db.tokenServiceURL, nil
}
// GetRealmData returns data attached to a realm.
func (db *SnapshotDB) GetRealmData(ctx context.Context, realm string) (*protocol.RealmData, error) {
// This may happen if the AuthDB proto has no Realms yet.
if db.realms == nil {
return nil, errors.Reason("Realms API is not available").Err()
}
// Verify such realm is defined in the DB or fallback to its @root.
if !db.realms.HasRealm(realm) {
if err := realms.ValidateRealmName(realm, realms.GlobalScope); err != nil {
return nil, err
}
project, _ := realms.Split(realm)
root := realms.Join(project, realms.RootRealm)
if realm == root || !db.realms.HasRealm(root) {
return nil, nil // no such project or it doesn't have realms.cfg
}
realm = root
}
data := db.realms.Data(realm)
if data == nil {
data = &protocol.RealmData{}
}
return data, nil
}