Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions cmd/server_broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ import (
"context"
"fmt"
"log"
"strings"
"time"

"cloud.google.com/go/compute/metadata"

"github.com/GoogleCloudPlatform/scion/pkg/api"
"github.com/GoogleCloudPlatform/scion/pkg/config"
"github.com/GoogleCloudPlatform/scion/pkg/runtime"
Expand Down Expand Up @@ -91,16 +94,32 @@ func registerGlobalProjectAndBroker(ctx context.Context, s store.Store, brokerID
"scion.io/broker-role": "embedded",
}

// Auto-detect host service account from the GCE metadata server.
// This works on GCE, Cloud Run, and GKE, and populates the fields
// that the passthrough identity gate requires.
var detectedSAEmail, detectedProjectID string
if metadata.OnGCE() {
if email, err := metadata.EmailWithContext(ctx, "default"); err == nil && email != "" {
detectedSAEmail = strings.ToLower(email)
log.Printf("Auto-detected GCP host service account: %s", detectedSAEmail)
}
if pid, err := metadata.ProjectIDWithContext(ctx); err == nil && pid != "" {
detectedProjectID = pid
}
}

if broker == nil {
broker = &store.RuntimeBroker{
ID: brokerID,
Name: brokerName,
Slug: api.Slugify(brokerName),
Version: "0.1.0",
Status: store.BrokerStatusOnline,
ConnectionState: "connected",
Endpoint: endpoint,
AutoProvide: autoProvide,
ID: brokerID,
Name: brokerName,
Slug: api.Slugify(brokerName),
Version: "0.1.0",
Status: store.BrokerStatusOnline,
ConnectionState: "connected",
Endpoint: endpoint,
AutoProvide: autoProvide,
GCPHostServiceAccountEmail: detectedSAEmail,
GCPHostProjectID: detectedProjectID,
Capabilities: &store.BrokerCapabilities{
WebPTY: false,
Sync: true,
Expand All @@ -120,6 +139,14 @@ func registerGlobalProjectAndBroker(ctx context.Context, s store.Store, brokerID
broker.Endpoint = endpoint
broker.AutoProvide = autoProvide
broker.LastHeartbeat = time.Now()
// Refresh host SA from metadata on every restart so the broker
// record stays current when the runtime SA changes.
if detectedSAEmail != "" {
broker.GCPHostServiceAccountEmail = detectedSAEmail
}
if detectedProjectID != "" {
broker.GCPHostProjectID = detectedProjectID
}
// Update profiles from settings (may have changed)
broker.Profiles = profiles
// Ensure deployment-type labels are set on re-registration
Expand Down
34 changes: 27 additions & 7 deletions pkg/hub/passthrough_gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ const (
)

// isValidServiceAccountEmail validates that an email address looks like a
// GCP service account email: <name>@<project>.iam.gserviceaccount.com.
// GCP service account email. Accepted formats:
//
// - Custom IAM SA: <name>@<project>.iam.gserviceaccount.com
// - Default Compute SA: <number>-compute@developer.gserviceaccount.com
// - App Engine default: <project-id>@appspot.gserviceaccount.com
func isValidServiceAccountEmail(email string) bool {
at := strings.IndexByte(email, '@')
if at <= 0 {
Expand All @@ -51,14 +55,20 @@ func isValidServiceAccountEmail(email string) bool {
return false
}

suffix := ".iam.gserviceaccount.com"
if !strings.HasSuffix(domain, suffix) {
switch {
case strings.HasSuffix(domain, ".iam.gserviceaccount.com"):
// Custom IAM SA: project ID portion must be non-empty.
projectID := domain[:len(domain)-len(".iam.gserviceaccount.com")]
return len(projectID) > 0
case domain == "developer.gserviceaccount.com":
// Default Compute Engine SA (e.g. <project-number>-compute@developer.gserviceaccount.com).
return true
Comment on lines +63 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For @developer.gserviceaccount.com service accounts, the project ID cannot be derived from the email address because the email contains the project number (e.g., 721899303052-compute), not the project ID (alphanumeric string), and the domain does not encode the project ID.

If GCPHostProjectID is left empty, the fallback projectIDFromServiceAccountEmail (called on line 158) will fail to extract a valid project ID, leading to failed GCP IAM checks.

Consider adding a validation check in the broker update/registration path to ensure GCPHostProjectID is explicitly provided when a @developer.gserviceaccount.com service account is used, or update projectIDFromServiceAccountEmail to explicitly handle or fail for this domain.

case domain == "appspot.gserviceaccount.com":
// App Engine default SA (e.g. <project-id>@appspot.gserviceaccount.com).
return true
default:
return false
}
Comment on lines +58 to 71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While adding support for @developer.gserviceaccount.com is great for default Compute Engine service accounts, another very common default GCP service account domain is @appspot.gserviceaccount.com (used as the App Engine default service account, which has the format {project-id}@appspot.gserviceaccount.com).

Consider also accepting appspot.gserviceaccount.com in this switch to support environments using the App Engine default service account.

	switch {
	case strings.HasSuffix(domain, ".iam.gserviceaccount.com"):
		// Custom IAM SA: project ID portion must be non-empty.
		projectID := domain[:len(domain)-len(".iam.gserviceaccount.com")]
		return len(projectID) > 0
	case domain == "developer.gserviceaccount.com":
		// Default Compute Engine SA (e.g. <project-number>-compute@developer.gserviceaccount.com).
		return true
	case domain == "appspot.gserviceaccount.com":
		// App Engine default SA (e.g. <project-id>@appspot.gserviceaccount.com).
		return true
	default:
		return false
	}


// Project ID portion must be non-empty.
projectID := domain[:len(domain)-len(suffix)]
return len(projectID) > 0
}

// authorizePassthroughIdentity gates passthrough mode for a caller against a
Expand Down Expand Up @@ -146,9 +156,19 @@ func (s *Server) authorizePassthroughIdentity(
// Synthesize a transient store.GCPServiceAccount target for the broker
// host SA. This is not persisted — it exists only as the target shape
// required by the frozen checker interface.
//
// For default Compute Engine SAs (@developer.gserviceaccount.com) and
// App Engine default SAs (@appspot.gserviceaccount.com), the project ID
// cannot be reliably extracted from the email (Compute SA emails
// contain the project NUMBER, not the project ID). The auto-detect
// path in registerGlobalProjectAndBroker sets GCPHostProjectID from
// the metadata server, which returns the correct project ID.
hostProjectID := broker.GCPHostProjectID
if hostProjectID == "" {
// Derive from the email when the operator did not set it explicitly.
// This only works for custom IAM SAs (<name>@<project>.iam.gserviceaccount.com).
// Default Compute SAs and App Engine SAs require GCPHostProjectID to be
// set explicitly (via auto-detection or manual configuration).
hostProjectID = projectIDFromServiceAccountEmail(broker.GCPHostServiceAccountEmail)
}

Expand Down
180 changes: 180 additions & 0 deletions pkg/hub/passthrough_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,31 @@ func TestIsValidServiceAccountEmail(t *testing.T) {
email string
valid bool
}{
// Custom IAM SAs
{"agent@my-project.iam.gserviceaccount.com", true},
{"scion-abc@my-project.iam.gserviceaccount.com", true},
{"my-sa@p.iam.gserviceaccount.com", true},
{"sa@my-long-project-id-1234.iam.gserviceaccount.com", true},

// Default Compute Engine SAs (@developer.gserviceaccount.com)
{"721899303052-compute@developer.gserviceaccount.com", true},
{"123456789-compute@developer.gserviceaccount.com", true},
{"my-sa@developer.gserviceaccount.com", true},

// App Engine default SAs (@appspot.gserviceaccount.com)
{"my-project@appspot.gserviceaccount.com", true},
{"my-long-project-id-1234@appspot.gserviceaccount.com", true},

// Invalid
{"", false},
{"not-an-email", false},
{"user@gmail.com", false},
{"@missing-name.iam.gserviceaccount.com", false},
{"sa@.iam.gserviceaccount.com", false},
{"sa@iam.gserviceaccount.com", false}, // no project id
{"@developer.gserviceaccount.com", false},
{"@appspot.gserviceaccount.com", false},
{"sa@other.gserviceaccount.com", false},
}
for _, tc := range tests {
t.Run(tc.email, func(t *testing.T) {
Expand Down Expand Up @@ -610,6 +624,114 @@ func TestPassthrough_AuditSurface_Create_Deny(t *testing.T) {
assert.Equal(t, store.ActAsDenied, *ev.Decision)
}

// ---------------------------------------------------------------------------
// Acceptance criterion: Default Compute Engine SA (developer domain) works
// ---------------------------------------------------------------------------

func TestPassthrough_DeveloperSA_Create_Allowed(t *testing.T) {
// A broker whose host SA is a default Compute Engine SA
// (@developer.gserviceaccount.com) must be accepted by the passthrough gate
// when GCPHostProjectID is set explicitly (auto-detected from metadata).
hostSAEmail := "721899303052-compute@developer.gserviceaccount.com"
hostProjectID := "ptone-experiments"
owner := ptUser(tid("user-pt-owner-dev-sa"), "owner-devsa@test.com", store.UserRoleMember)
srv, _, project, _ := setupPassthroughServer(t, owner, hostSAEmail, hostProjectID)

checker := store.NewFakeCallerPermissionChecker().AllowTarget(hostSAEmail)
enforceSAAssign(srv, checker)

rec := doRequestAsUser(t, srv, owner, http.MethodPost, "/api/v1/agents", CreateAgentRequest{
Name: "pt-developer-sa",
ProjectID: project.ID,
Task: "test",
GCPIdentity: &GCPIdentityAssignment{
MetadataMode: "passthrough",
},
})

require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
require.GreaterOrEqual(t, checker.CallCount(), 1,
"the caller-permission checker must be consulted for developer SA")

var resp CreateAgentResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotNil(t, resp.Agent.AppliedConfig.GCPIdentity)
assert.Equal(t, store.GCPMetadataModePassthrough, resp.Agent.AppliedConfig.GCPIdentity.MetadataMode)
}

func TestPassthrough_DeveloperSA_Patch_Allowed(t *testing.T) {
hostSAEmail := "721899303052-compute@developer.gserviceaccount.com"
hostProjectID := "ptone-experiments"
owner := ptUser(tid("user-pt-owner-dev-sa-2"), "owner-devsa2@test.com", store.UserRoleMember)
srv, s, project, _ := setupPassthroughServer(t, owner, hostSAEmail, hostProjectID)

agent := &store.Agent{
ID: tid("agent-pt-dev-sa-patch"),
Slug: "pt-dev-sa-patch",
Name: "pt-dev-sa-patch",
ProjectID: project.ID,
RuntimeBrokerID: tid("broker-pt"),
Phase: string(state.PhaseCreated),
CreatedBy: owner.ID,
OwnerID: owner.ID,
}
require.NoError(t, s.CreateAgent(context.Background(), agent))

checker := store.NewFakeCallerPermissionChecker().AllowTarget(hostSAEmail)
enforceSAAssign(srv, checker)

rec := doRequestAsUser(t, srv, owner, http.MethodPatch, "/api/v1/agents/"+agent.ID,
map[string]interface{}{
"gcp_identity": map[string]interface{}{
"metadata_mode": "passthrough",
},
})

require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
require.GreaterOrEqual(t, checker.CallCount(), 1)

got, err := s.GetAgent(context.Background(), agent.ID)
require.NoError(t, err)
require.NotNil(t, got.AppliedConfig)
require.NotNil(t, got.AppliedConfig.GCPIdentity)
assert.Equal(t, store.GCPMetadataModePassthrough, got.AppliedConfig.GCPIdentity.MetadataMode)
}

// ---------------------------------------------------------------------------
// Acceptance criterion: App Engine default SA (appspot domain) works
// ---------------------------------------------------------------------------

func TestPassthrough_AppspotSA_Create_Allowed(t *testing.T) {
// A broker whose host SA is an App Engine default SA
// (@appspot.gserviceaccount.com) must be accepted by the passthrough gate
// when GCPHostProjectID is set explicitly.
hostSAEmail := "my-project@appspot.gserviceaccount.com"
hostProjectID := "my-project"
owner := ptUser(tid("user-pt-owner-appspot"), "owner-appspot@test.com", store.UserRoleMember)
srv, _, project, _ := setupPassthroughServer(t, owner, hostSAEmail, hostProjectID)

checker := store.NewFakeCallerPermissionChecker().AllowTarget(hostSAEmail)
enforceSAAssign(srv, checker)

rec := doRequestAsUser(t, srv, owner, http.MethodPost, "/api/v1/agents", CreateAgentRequest{
Name: "pt-appspot-sa",
ProjectID: project.ID,
Task: "test",
GCPIdentity: &GCPIdentityAssignment{
MetadataMode: "passthrough",
},
})

require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
require.GreaterOrEqual(t, checker.CallCount(), 1,
"the caller-permission checker must be consulted for appspot SA")

var resp CreateAgentResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotNil(t, resp.Agent.AppliedConfig.GCPIdentity)
assert.Equal(t, store.GCPMetadataModePassthrough, resp.Agent.AppliedConfig.GCPIdentity.MetadataMode)
}

// ---------------------------------------------------------------------------
// Broker registration: host SA fields
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -644,6 +766,64 @@ func TestBrokerUpdate_GCPHostSAFields(t *testing.T) {
assert.Equal(t, "my-project", got.GCPHostProjectID)
}

func TestBrokerUpdate_GCPHostSAEmail_DeveloperDomain(t *testing.T) {
srv, s := testServer(t)
ctx := context.Background()

admin := addExtraUser(t, s, tid("admin-broker-devsa"), "admin-devsa@test.com", store.UserRoleAdmin)

broker := &store.RuntimeBroker{
ID: tid("broker-devsa"),
Name: "Developer SA Broker",
Slug: "developer-sa-broker",
Status: store.BrokerStatusOnline,
CreatedBy: admin.ID,
}
require.NoError(t, s.CreateRuntimeBroker(ctx, broker))

// PATCH with developer.gserviceaccount.com email.
rec := doRequestAsUser(t, srv, admin, http.MethodPatch, "/api/v1/runtime-brokers/"+broker.ID,
map[string]interface{}{
"gcpHostServiceAccountEmail": "721899303052-compute@developer.gserviceaccount.com",
"gcpHostProjectId": "ptone-experiments",
})
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())

got, err := s.GetRuntimeBroker(ctx, broker.ID)
require.NoError(t, err)
assert.Equal(t, "721899303052-compute@developer.gserviceaccount.com", got.GCPHostServiceAccountEmail)
assert.Equal(t, "ptone-experiments", got.GCPHostProjectID)
}

func TestBrokerUpdate_GCPHostSAEmail_AppspotDomain(t *testing.T) {
srv, s := testServer(t)
ctx := context.Background()

admin := addExtraUser(t, s, tid("admin-broker-appspot"), "admin-appspot@test.com", store.UserRoleAdmin)

broker := &store.RuntimeBroker{
ID: tid("broker-appspot"),
Name: "Appspot SA Broker",
Slug: "appspot-sa-broker",
Status: store.BrokerStatusOnline,
CreatedBy: admin.ID,
}
require.NoError(t, s.CreateRuntimeBroker(ctx, broker))

// PATCH with appspot.gserviceaccount.com email.
rec := doRequestAsUser(t, srv, admin, http.MethodPatch, "/api/v1/runtime-brokers/"+broker.ID,
map[string]interface{}{
"gcpHostServiceAccountEmail": "my-project@appspot.gserviceaccount.com",
"gcpHostProjectId": "my-project",
})
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())

got, err := s.GetRuntimeBroker(ctx, broker.ID)
require.NoError(t, err)
assert.Equal(t, "my-project@appspot.gserviceaccount.com", got.GCPHostServiceAccountEmail)
assert.Equal(t, "my-project", got.GCPHostProjectID)
}

func TestBrokerUpdate_GCPHostSAEmail_InvalidFormat(t *testing.T) {
srv, s := testServer(t)
ctx := context.Background()
Expand Down
Loading