go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/auth/authdb/snapshot.go (about) 1 // Copyright 2016 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 package authdb 16 17 import ( 18 "context" 19 "io" 20 "net" 21 22 "github.com/golang/protobuf/proto" 23 "go.opentelemetry.io/otel/attribute" 24 25 "go.chromium.org/luci/auth/identity" 26 "go.chromium.org/luci/common/errors" 27 "go.chromium.org/luci/common/logging" 28 29 "go.chromium.org/luci/server/auth/authdb/internal/certs" 30 "go.chromium.org/luci/server/auth/authdb/internal/graph" 31 "go.chromium.org/luci/server/auth/authdb/internal/ipaddr" 32 "go.chromium.org/luci/server/auth/authdb/internal/oauthid" 33 "go.chromium.org/luci/server/auth/authdb/internal/realmset" 34 "go.chromium.org/luci/server/auth/authdb/internal/seccfg" 35 "go.chromium.org/luci/server/auth/internal/tracing" 36 "go.chromium.org/luci/server/auth/realms" 37 "go.chromium.org/luci/server/auth/service/protocol" 38 "go.chromium.org/luci/server/auth/signing" 39 ) 40 41 // SnapshotDB implements DB using AuthDB proto message. 42 // 43 // Use NewSnapshotDB to create new instances. Don't touch public fields 44 // of existing instances. 45 // 46 // Zero value represents an empty AuthDB. 47 type SnapshotDB struct { 48 AuthServiceURL string // where it was fetched from 49 Rev int64 // its revision number 50 51 groups *graph.QueryableGraph // queryable representation of groups 52 realms *realmset.Realms // queryable representation of realms 53 clientIDs oauthid.Allowlist // set of allowed client IDs 54 allowlistedIPs ipaddr.Allowlist // set of named IP allowlist 55 securityCfg *seccfg.SecurityConfig // parsed SecurityConfig proto 56 57 tokenServiceURL string // URL of the token server as provided by Auth service 58 tokenServiceCerts certs.Bundle // cached public keys of the token server 59 } 60 61 var _ DB = &SnapshotDB{} 62 63 // Revision returns a revision of an auth DB or 0 if it can't be determined. 64 // 65 // It's just a small helper that casts db to *SnapshotDB and extracts the 66 // revision from there. 67 func Revision(db DB) int64 { 68 if snap, _ := db.(*SnapshotDB); snap != nil { 69 return snap.Rev 70 } 71 return 0 72 } 73 74 // SnapshotDBFromTextProto constructs SnapshotDB by loading it from a text proto 75 // with AuthDB message. 76 func SnapshotDBFromTextProto(r io.Reader) (*SnapshotDB, error) { 77 blob, err := io.ReadAll(r) 78 if err != nil { 79 return nil, errors.Annotate(err, "failed to read the file").Err() 80 } 81 msg := &protocol.AuthDB{} 82 if err := proto.UnmarshalText(string(blob), msg); err != nil { 83 return nil, errors.Annotate(err, "not a valid AuthDB text proto file").Err() 84 } 85 db, err := NewSnapshotDB(msg, "", 0, true) 86 if err != nil { 87 return nil, errors.Annotate(err, "failed to validate AuthDB").Err() 88 } 89 return db, nil 90 } 91 92 // NewSnapshotDB creates new instance of SnapshotDB. 93 // 94 // It does some preprocessing to speed up subsequent checks. Returns errors if 95 // it encounters inconsistencies. 96 // 97 // If 'validate' is false, skips some expensive validation steps, assuming they 98 // were performed before, when AuthDB was initially received. 99 func NewSnapshotDB(authDB *protocol.AuthDB, authServiceURL string, rev int64, validate bool) (*SnapshotDB, error) { 100 if validate { 101 if err := validateAuthDB(authDB); err != nil { 102 return nil, err 103 } 104 } 105 106 groups, err := graph.BuildQueryable(authDB.Groups) 107 if err != nil { 108 return nil, errors.Annotate(err, "failed to build groups graph").Err() 109 } 110 111 var realmSet *realmset.Realms 112 if authDB.Realms != nil { 113 realmSet, err = realmset.Build(authDB.Realms, groups, realms.RegisteredPermissions()) 114 if err != nil { 115 return nil, errors.Annotate(err, "failed to prepare Realms DB").Err() 116 } 117 } 118 119 allowlistedIPs, err := ipaddr.NewAllowlist(authDB.IpWhitelists, authDB.IpWhitelistAssignments) 120 if err != nil { 121 return nil, errors.Annotate(err, "bad IP allowlist in AuthDB").Err() 122 } 123 124 securityCfg, err := seccfg.Parse(authDB.SecurityConfig) 125 if err != nil { 126 return nil, errors.Annotate(err, "bad SecurityConfig").Err() 127 } 128 129 return &SnapshotDB{ 130 AuthServiceURL: authServiceURL, 131 Rev: rev, 132 groups: groups, 133 realms: realmSet, 134 clientIDs: oauthid.NewAllowlist(authDB.OauthClientId, authDB.OauthAdditionalClientIds), 135 allowlistedIPs: allowlistedIPs, 136 securityCfg: securityCfg, 137 tokenServiceURL: authDB.TokenServerUrl, 138 tokenServiceCerts: certs.Bundle{ServiceURL: authDB.TokenServerUrl}, 139 }, nil 140 } 141 142 // IsAllowedOAuthClientID returns true if the given OAuth2 client ID can be used 143 // to authorize access from the given email. 144 func (db *SnapshotDB) IsAllowedOAuthClientID(_ context.Context, email, clientID string) (bool, error) { 145 return db.clientIDs.IsAllowedOAuthClientID(email, clientID), nil 146 } 147 148 // IsInternalService returns true if the given hostname belongs to a service 149 // that is a part of the current LUCI deployment. 150 // 151 // What hosts are internal is controlled by 'internal_service_regexp' setting 152 // in security.cfg in the Auth Service configs. 153 func (db *SnapshotDB) IsInternalService(ctx context.Context, hostname string) (bool, error) { 154 if db.securityCfg != nil { 155 return db.securityCfg.IsInternalService(hostname), nil 156 } 157 return false, nil 158 } 159 160 // IsMember returns true if the given identity belongs to any of the groups. 161 // 162 // Unknown groups are considered empty, but are logged as warnings. 163 // May return errors if underlying datastore has issues. 164 func (db *SnapshotDB) IsMember(ctx context.Context, id identity.Identity, groups []string) (ok bool, err error) { 165 _, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.IsMember", 166 attribute.StringSlice("cr.dev.groups", groups), 167 ) 168 defer func() { tracing.End(span, err, attribute.Bool("cr.dev.outcome", ok)) }() 169 170 if db.groups == nil { 171 return false, nil 172 } 173 174 // TODO(vadimsh): Optimize multi-group case. 175 for _, gr := range groups { 176 switch db.groups.IsMember(id, gr) { 177 case graph.IdentIsMember: 178 return true, nil 179 case graph.GroupIsUnknown: 180 logging.Warningf(ctx, "Group %q is unknown in auth db snapshot %d", gr, db.Rev) 181 } 182 } 183 return false, nil 184 } 185 186 // CheckMembership returns groups from the given list the identity belongs to. 187 // 188 // Unlike IsMember, it doesn't stop on the first hit but continues evaluating 189 // all groups. 190 // 191 // Unknown groups are considered empty. The order of groups in the result may 192 // be different from the order in 'groups'. 193 // 194 // May return errors if underlying datastore has issues. 195 func (db *SnapshotDB) CheckMembership(ctx context.Context, id identity.Identity, groups []string) (out []string, err error) { 196 _, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.CheckMembership", 197 attribute.StringSlice("cr.dev.groups", groups), 198 ) 199 defer func() { tracing.End(span, err, attribute.StringSlice("cr.dev.outcome", out)) }() 200 201 if db.groups == nil { 202 return 203 } 204 205 // TODO(vadimsh): Optimize multi-group case. 206 for _, gr := range groups { 207 switch db.groups.IsMember(id, gr) { 208 case graph.IdentIsMember: 209 out = append(out, gr) 210 case graph.GroupIsUnknown: 211 logging.Warningf(ctx, "Group %q is unknown in auth db snapshot %d", gr, db.Rev) 212 } 213 } 214 return 215 } 216 217 // HasPermission returns true if the identity has the given permission in the 218 // realm. 219 func (db *SnapshotDB) HasPermission(ctx context.Context, id identity.Identity, perm realms.Permission, realm string, attrs realms.Attrs) (ok bool, err error) { 220 otelAttrs := append(make([]attribute.KeyValue, 0, 2+len(attrs)), 221 attribute.String("cr.dev.permission", perm.Name()), 222 attribute.String("cr.dev.realm", realm), 223 ) 224 for k, v := range attrs { 225 otelAttrs = append(otelAttrs, attribute.String("cr.dev.attr."+k, v)) 226 } 227 ctx, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.HasPermission", 228 otelAttrs..., 229 ) 230 defer func() { tracing.End(span, err, attribute.Bool("cr.dev.outcome", ok)) }() 231 232 // This may happen if the AuthDB proto has no Realms yet. 233 if db.realms == nil { 234 return false, errors.Reason("Realms API is not available").Err() 235 } 236 237 permIdx, ok := db.realms.PermissionIndex(perm) 238 if !ok { 239 logging.Warningf(ctx, "Checking permission %q not present in the AuthDB", perm) 240 return false, nil 241 } 242 243 // Verify such realm is defined in the DB or fallback to its @root. 244 if !db.realms.HasRealm(realm) { 245 if err := realms.ValidateRealmName(realm, realms.GlobalScope); err != nil { 246 return false, errors.Annotate(err, "when checking %q", perm).Err() 247 } 248 project, name := realms.Split(realm) 249 root := realms.Join(project, realms.RootRealm) 250 if realm == root { 251 logging.Warningf(ctx, "Checking %q in a non-existing root realm %q: denying", perm, realm) 252 return false, nil 253 } 254 if !db.realms.HasRealm(root) { 255 logging.Warningf(ctx, "Checking %q in a non-existing realm %q that doesn't have a root realm (no such project?): denying", perm, realm) 256 return false, nil 257 } 258 // Don't log @legacy => @root fallbacks, they are semi-expected. 259 if name != realms.LegacyRealm { 260 logging.Warningf(ctx, "Checking %q in a non-existing realm %q: falling back to the root realm %q", perm, realm, root) 261 } 262 realm = root 263 } 264 265 // Grab the list of bindings for this permission and check if any applies to 266 // the `id` based on its group memberships. 267 q := db.groups.MembershipsQueryCache(id) 268 return db.realms.Bindings(realm, permIdx).Check(ctx, &q, attrs), nil 269 } 270 271 // QueryRealms returns a list of realms where the identity has the given 272 // permission. 273 func (db *SnapshotDB) QueryRealms(ctx context.Context, id identity.Identity, perm realms.Permission, project string, attrs realms.Attrs) (out []string, err error) { 274 otelAttrs := append(make([]attribute.KeyValue, 0, 2+len(attrs)), 275 attribute.String("cr.dev.permission", perm.Name()), 276 attribute.String("cr.dev.project", project), 277 ) 278 for k, v := range attrs { 279 otelAttrs = append(otelAttrs, attribute.String("cr.dev.attr."+k, v)) 280 } 281 ctx, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.QueryRealms", 282 otelAttrs..., 283 ) 284 // `out` list can be huge. Just report the number of realms. 285 defer func() { tracing.End(span, err, attribute.Int("cr.dev.outcome", len(out))) }() 286 287 if project != "" { 288 if err := realms.ValidateProjectName(project); err != nil { 289 return nil, err 290 } 291 } 292 293 // This may happen if the AuthDB proto has no Realms yet. 294 if db.realms == nil { 295 return nil, errors.Reason("Realms API is not available").Err() 296 } 297 298 permIdx, ok := db.realms.PermissionIndex(perm) 299 if !ok { 300 logging.Warningf(ctx, "Querying realms with permission %q not present in the AuthDB", perm) 301 return nil, nil 302 } 303 304 // Get the map project => all bindings for the given permission there. This 305 // returns `ok == false` if the permission was not flagged with 306 // UsedInQueryRealms. 307 permBindings, ok := db.realms.QueryBindings(permIdx) 308 if !ok { 309 return nil, errors.Reason("permission %s cannot be used in QueryRealms: it was not flagged with UsedInQueryRealms flag", perm).Err() 310 } 311 312 // For each potentially matching list of bindings, check if it really matches. 313 q := db.groups.MembershipsQueryCache(id) 314 visit := func(bindings []realmset.RealmBindings) { 315 for _, realmBindings := range bindings { 316 if realmBindings.Bindings.Check(ctx, &q, attrs) { 317 out = append(out, realmBindings.Realm) 318 } 319 } 320 } 321 if project != "" { 322 visit(permBindings[project]) 323 } else { 324 for _, bindings := range permBindings { 325 visit(bindings) 326 } 327 } 328 329 return out, nil 330 } 331 332 // FilterKnownGroups filters the list of groups keeping only ones that exist. 333 // 334 // May return errors if underlying datastore has issues. If all groups are 335 // unknown, returns an empty list and no error. 336 func (db *SnapshotDB) FilterKnownGroups(ctx context.Context, groups []string) (known []string, err error) { 337 _, span := tracing.Start(ctx, "go.chromium.org/luci/server/auth/authdb.FilterKnownGroups", 338 attribute.Int("cr.dev.groups", len(groups)), 339 ) 340 defer func() { tracing.End(span, err, attribute.Int("cr.dev.outcome", len(known))) }() 341 342 if db.groups == nil { 343 return nil, nil 344 } 345 346 known = make([]string, 0, len(groups)) 347 for _, gr := range groups { 348 if _, ok := db.groups.GroupIndex(gr); ok { 349 known = append(known, gr) 350 } 351 } 352 return known, nil 353 } 354 355 // GetCertificates returns a bundle with certificates of a trusted signer. 356 // 357 // Currently only the Token Server is a trusted signer. 358 func (db *SnapshotDB) GetCertificates(ctx context.Context, signerID identity.Identity) (*signing.PublicCertificates, error) { 359 if db.tokenServiceURL == "" { 360 logging.Warningf( 361 ctx, "Delegation is not supported, the token server URL is not set by %s", 362 db.AuthServiceURL) 363 return nil, nil 364 } 365 switch tokenServerID, certs, err := db.tokenServiceCerts.GetCerts(ctx); { 366 case err != nil: 367 return nil, err 368 case signerID != tokenServerID: 369 return nil, nil // signerID is not trusted since it's not a token server 370 default: 371 return certs, nil 372 } 373 } 374 375 // GetAllowlistForIdentity returns name of the IP allowlist to use to check 376 // IP of requests from the given `ident`. 377 // 378 // It's used to restrict access for certain account to certain IP subnets. 379 // 380 // Returns ("", nil) if `ident` is not IP restricted. 381 func (db *SnapshotDB) GetAllowlistForIdentity(ctx context.Context, ident identity.Identity) (string, error) { 382 return db.allowlistedIPs.GetAllowlistForIdentity(ident), nil 383 } 384 385 // IsAllowedIP returns true if IP address belongs to given named IP allowlist. 386 func (db *SnapshotDB) IsAllowedIP(ctx context.Context, ip net.IP, allowlist string) (bool, error) { 387 return db.allowlistedIPs.IsAllowedIP(ip, allowlist), nil 388 } 389 390 // GetAuthServiceURL returns root URL ("https://<host>") of the auth service 391 // the snapshot was fetched from. 392 // 393 // This is needed to implement authdb.DB interface. 394 func (db *SnapshotDB) GetAuthServiceURL(ctx context.Context) (string, error) { 395 if db.AuthServiceURL == "" { 396 return "", errors.Reason("not using Auth Service").Err() 397 } 398 return db.AuthServiceURL, nil 399 } 400 401 // GetTokenServiceURL returns root URL ("https://<host>") of the token server. 402 // 403 // This is needed to implement authdb.DB interface. 404 func (db *SnapshotDB) GetTokenServiceURL(ctx context.Context) (string, error) { 405 return db.tokenServiceURL, nil 406 } 407 408 // GetRealmData returns data attached to a realm. 409 func (db *SnapshotDB) GetRealmData(ctx context.Context, realm string) (*protocol.RealmData, error) { 410 // This may happen if the AuthDB proto has no Realms yet. 411 if db.realms == nil { 412 return nil, errors.Reason("Realms API is not available").Err() 413 } 414 415 // Verify such realm is defined in the DB or fallback to its @root. 416 if !db.realms.HasRealm(realm) { 417 if err := realms.ValidateRealmName(realm, realms.GlobalScope); err != nil { 418 return nil, err 419 } 420 project, _ := realms.Split(realm) 421 root := realms.Join(project, realms.RootRealm) 422 if realm == root || !db.realms.HasRealm(root) { 423 return nil, nil // no such project or it doesn't have realms.cfg 424 } 425 realm = root 426 } 427 428 data := db.realms.Data(realm) 429 if data == nil { 430 data = &protocol.RealmData{} 431 } 432 return data, nil 433 }