@@ -236,7 +236,45 @@ func buildCertPermissions(cert *ssh.Certificate, extensions map[string]string) *
236236 }
237237}
238238
239- func (s * serverConfig ) handleUserCertificate (ctx context.Context , user string , cert * ssh.Certificate ) (* ssh.Permissions , error ) { //nolint:funlen
239+ // validateInstanceKeyID applies the KeyId format rules that an instance-wide
240+ // grant requires, and logs the rejection. Both a locally trusted CA and an
241+ // instance-scoped API response resolve KeyId directly to a username, so they
242+ // hold it to the same standard.
243+ func validateInstanceKeyID (ctx context.Context , keyID string ) error {
244+ if err := validateKeyID (keyID ); err != nil {
245+ log .FromContext (ctx ).WarnContext (ctx , "instance-level certificate rejected: invalid KeyId" ,
246+ log .ErrorMessage (err .Error ()))
247+ return fmt .Errorf ("handleUserCertificate: %w" , err )
248+ }
249+
250+ return nil
251+ }
252+
253+ // grantCertificate logs an authorized certificate and builds its permissions.
254+ // An empty namespace means instance-wide access: no namespace key is set, which
255+ // is what downstream reads as an unrestricted grant.
256+ func grantCertificate (ctx context.Context , cert * ssh.Certificate , username , namespace , msg string ) * ssh.Permissions {
257+ extensions := map [string ]string {certPermUsername : username }
258+
259+ scope := "instance"
260+ if namespace != "" {
261+ scope = "group"
262+ extensions [certPermNamespace ] = namespace
263+ ctx = log .AppendFields (ctx , slog .String ("certificate_namespace" , namespace ))
264+ }
265+
266+ ctx = log .AppendFields (ctx , slog .String ("certificate_scope" , scope ))
267+ log .FromContext (ctx ).InfoContext (ctx , msg )
268+
269+ if addr , ok := cert .CriticalOptions ["source-address" ]; ok {
270+ log .FromContext (ctx ).InfoContext (ctx , "certificate authorized with source-address restriction" ,
271+ slog .String ("source_address" , addr ))
272+ }
273+
274+ return buildCertPermissions (cert , extensions )
275+ }
276+
277+ func (s * serverConfig ) handleUserCertificate (ctx context.Context , user string , cert * ssh.Certificate ) (* ssh.Permissions , error ) {
240278 fingerprint := ssh .FingerprintSHA256 (cert .SignatureKey )
241279
242280 // Enrich context early so all rejection paths include audit-relevant fields.
@@ -261,53 +299,76 @@ func (s *serverConfig) handleUserCertificate(ctx context.Context, user string, c
261299 }
262300
263301 if s .isLocallyTrustedCA (cert .SignatureKey ) {
264- if err := validateKeyID (cert .KeyId ); err != nil {
265- log .FromContext (ctx ).WarnContext (ctx , "instance-level certificate rejected: invalid KeyId" ,
266- log .ErrorMessage (err .Error ()))
267- return nil , fmt .Errorf ("handleUserCertificate: %w" , err )
302+ if err := validateInstanceKeyID (ctx , cert .KeyId ); err != nil {
303+ return nil , err
268304 }
269305
270306 ctx = log .AppendFields (ctx , slog .String ("certificate_username" , cert .KeyId ))
271- log .FromContext (ctx ).InfoContext (ctx , "user certificate is signed by a locally trusted CA (instance-level)" )
272307
273- if addr , ok := cert .CriticalOptions ["source-address" ]; ok {
274- log .FromContext (ctx ).InfoContext (ctx , "certificate authorized with source-address restriction" ,
275- slog .String ("source_address" , addr ))
276- }
277-
278- // No namespace key = instance-wide access (no namespace restriction)
279- return buildCertPermissions (cert , map [string ]string {
280- certPermUsername : cert .KeyId ,
281- }), nil
308+ return grantCertificate (ctx , cert , cert .KeyId , "" ,
309+ "user certificate is signed by a locally trusted CA (instance-level)" ), nil
282310 }
283311
284- // Fall back to group-level certificate check via Rails API
312+ // Fall back to certificate resolution via the Rails API (group- or instance-level)
285313 if os .Getenv ("FF_GITLAB_SHELL_SSH_CERTIFICATES" ) != "1" {
286314 return nil , fmt .Errorf ("handleUserCertificate: feature is disabled" )
287315 }
288316
317+ return s .resolveCertificateViaAPI (ctx , cert , fingerprint )
318+ }
319+
320+ // resolveCertificateViaAPI authenticates a certificate whose signing CA is not
321+ // locally trusted, by asking the Rails API to resolve the CA fingerprint. The
322+ // response identifies whether the match was instance-scoped, which grants
323+ // instance-wide access, or group-scoped, which restricts access to a namespace.
324+ func (s * serverConfig ) resolveCertificateViaAPI (ctx context.Context , cert * ssh.Certificate , fingerprint string ) (* ssh.Permissions , error ) {
289325 res , err := s .authorizedCertsClient .GetByKey (ctx , cert .KeyId , strings .TrimPrefix (fingerprint , "SHA256:" ))
290326 if err != nil {
291327 log .FromContext (ctx ).WarnContext (ctx , "user certificate is not signed by a trusted key" , log .ErrorMessage (err .Error ()))
292328 return nil , err
293329 }
294330
295- ctx = log . AppendFields ( ctx ,
296- slog . String ( "certificate_username" , res . Username ),
297- slog . String ( "certificate_namespace" , res . Namespace ),
298- )
331+ if res . Username == "" {
332+ log . FromContext ( ctx ). WarnContext ( ctx , "certificate rejected: response has no username" )
333+ return nil , fmt . Errorf ( "handleUserCertificate: response missing username" )
334+ }
299335
300- log .FromContext (ctx ). InfoContext ( ctx , "user certificate is signed by a trusted key (group-level)" )
336+ ctx = log .AppendFields (ctx , slog . String ( "certificate_username" , res . Username ) )
301337
302- if addr , ok := cert .CriticalOptions ["source-address" ]; ok {
303- log .FromContext (ctx ).InfoContext (ctx , "certificate authorized with source-address restriction" ,
304- slog .String ("source_address" , addr ))
338+ if res .Instance {
339+ // Rails never sets a namespace on an instance-scoped match, so one
340+ // here means the response is inconsistent; reject it rather than
341+ // guess which scope was intended.
342+ if res .Namespace != "" {
343+ log .FromContext (ctx ).WarnContext (ctx , "certificate rejected: instance-scoped response has a namespace" )
344+ return nil , fmt .Errorf ("handleUserCertificate: instance-scoped response has unexpected namespace" )
345+ }
346+
347+ // Instance-level CAs grant the same instance-wide trust as locally
348+ // trusted ones, so hold their KeyId to the same standard. This runs
349+ // after the API call, not before it, because the rule applies
350+ // only to instance-scoped results and the scope is not known until the
351+ // response arrives; group-level resolution does accept an email
352+ // identifier, which validateKeyID's pattern rejects. No permissions
353+ // have been granted yet, so a malformed KeyId never reaches a session.
354+ if err := validateInstanceKeyID (ctx , cert .KeyId ); err != nil {
355+ return nil , err
356+ }
357+
358+ return grantCertificate (ctx , cert , res .Username , "" ,
359+ "user certificate is signed by a trusted key (instance-level)" ), nil
360+ }
361+
362+ // A group-scoped match without a namespace would be indistinguishable from
363+ // an instance-scoped one downstream, so fail closed rather than widening
364+ // the grant to the whole instance.
365+ if res .Namespace == "" {
366+ log .FromContext (ctx ).WarnContext (ctx , "certificate rejected: group-scoped response has no namespace" )
367+ return nil , fmt .Errorf ("handleUserCertificate: group-scoped response missing namespace" )
305368 }
306369
307- return buildCertPermissions (cert , map [string ]string {
308- certPermUsername : res .Username ,
309- certPermNamespace : res .Namespace ,
310- }), nil
370+ return grantCertificate (ctx , cert , res .Username , res .Namespace ,
371+ "user certificate is signed by a trusted key (group-level)" ), nil
311372}
312373
313374// publicKeyCallback returns the SSH PublicKeyCallback. It authenticates the key
0 commit comments