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
36 changes: 34 additions & 2 deletions packages/api/internal/handlers/template_alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ package handlers
import (
"fmt"
"net/http"
"strings"

"github.com/gin-gonic/gin"

"github.com/e2b-dev/infra/packages/api/internal/api"
templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates"
"github.com/e2b-dev/infra/packages/db/pkg/dberrors"
"github.com/e2b-dev/infra/packages/db/queries"
"github.com/e2b-dev/infra/packages/shared/pkg/id"
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
)
Expand All @@ -23,7 +26,8 @@ func (a *APIStore) GetTemplatesAliasesAlias(c *gin.Context, alias string) {
return
}

identifier, _, err := id.ParseName(alias)
hasExplicitTag := strings.Contains(alias, id.TagSeparator)
identifier, tag, err := id.ParseName(alias)
if err != nil {
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid alias format: %s", err))
telemetry.ReportError(ctx, "invalid alias format", err)
Expand All @@ -45,13 +49,41 @@ func (a *APIStore) GetTemplatesAliasesAlias(c *gin.Context, alias string) {
return
}

// Ownership verification (handles edge case where template was transferred)
// Ownership verification (handles edge case where template was transferred).
// Must run before the tag-existence probe below, otherwise non-owners could
// distinguish existing tags from missing ones on templates they no longer
// have access to via 404 vs 403 responses.
if aliasInfo.TeamID != team.ID {
a.sendAPIStoreError(c, http.StatusForbidden, "You don't have access to this template alias")

return
}

if hasExplicitTag {
tagValue := id.DefaultTag
if tag != nil {
tagValue = *tag
}

_, err = a.sqlcDB.GetTemplateWithBuildByTag(ctx, queries.GetTemplateWithBuildByTagParams{
TemplateID: aliasInfo.TemplateID,
Tag: &tagValue,
})
Comment thread
drankou marked this conversation as resolved.
if err != nil {
if dberrors.IsNotFoundError(err) {
a.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("tag '%s' does not exist for template '%s'", tagValue, identifier))
telemetry.ReportError(ctx, "template tag not found", err, telemetry.WithTemplateID(aliasInfo.TemplateID))

return
}

a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when checking template tag existence")
telemetry.ReportCriticalError(ctx, "error when checking template tag existence", err, telemetry.WithTemplateID(aliasInfo.TemplateID))

return
}
}
Comment thread
drankou marked this conversation as resolved.

// Team is alias owner
c.JSON(
http.StatusOK, api.TemplateAliasResponse{
Expand Down
193 changes: 193 additions & 0 deletions packages/api/internal/handlers/template_alias_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package handlers

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand All @@ -16,9 +18,37 @@ import (
"github.com/e2b-dev/infra/packages/auth/pkg/types"
authqueries "github.com/e2b-dev/infra/packages/db/pkg/auth/queries"
"github.com/e2b-dev/infra/packages/db/pkg/testutils"
"github.com/e2b-dev/infra/packages/shared/pkg/id"
redis_utils "github.com/e2b-dev/infra/packages/shared/pkg/redis"
)

func queryTemplateAliasForTeam(
t *testing.T,
store *APIStore,
teamID uuid.UUID,
teamSlug string,
alias string,
) *apispec.GetTemplatesAliasesAliasResponse {
t.Helper()

w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/templates/aliases/%s", alias), nil)
auth.SetTeamInfoForTest(t, c, &types.Team{
Team: &authqueries.Team{
ID: teamID,
Slug: teamSlug,
},
})

store.GetTemplatesAliasesAlias(c, alias)

res, err := apispec.ParseGetTemplatesAliasesAliasResponse(w.Result())
require.NoError(t, err)

return res
}

func TestQueryNotExistingTemplateAlias(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -138,3 +168,166 @@ func TestQueryExistingTemplateAliasAsNotOwnerTeam(t *testing.T) {
// This results in 404 (not found) instead of 403 (forbidden) with the new exact match behavior
require.Equal(t, http.StatusNotFound, res.StatusCode())
}

// TestTaggedTemplateAliasTransferredTemplate verifies that when an alias still
// resolves to a template whose ownership has moved to a different team, the
// ownership check returns 403 *before* the tag-existence probe runs. Otherwise
// a non-owner could distinguish existing tags from missing ones on a template
// they no longer have access to via the 404 vs 403 response.
func TestTaggedTemplateAliasTransferredTemplate(t *testing.T) {
t.Parallel()

tests := []struct {
name string
setTags func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string)
tag string
}{
{
name: "tag exists on transferred template",
setTags: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string) {
t.Helper()
buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded")
testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "dev")
},
tag: "dev",
},
{
name: "tag missing on transferred template",
setTags: func(_ *testing.T, _ context.Context, _ *testutils.Database, _ string) {},
tag: "dev",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

testDB := testutils.SetupDatabase(t)
redis := redis_utils.SetupInstance(t)
ctx := t.Context()

requesterTeamID := testutils.CreateTestTeam(t, testDB)
requesterTeamSlug := testutils.GetTeamSlug(t, ctx, testDB, requesterTeamID)
newOwnerTeamID := testutils.CreateTestTeam(t, testDB)

// Template owned by the new team but alias still lives in the requester's
// namespace, mirroring the post-transfer state the in-code comment guards.
templateID := testutils.CreateTestTemplate(t, testDB, newOwnerTeamID)
alias := testutils.CreateTestTemplateAliasWithNamespace(t, testDB, templateID, &requesterTeamSlug)

tt.setTags(t, ctx, testDB, templateID)

store := &APIStore{
sqlcDB: testDB.SqlcClient,
authDB: testDB.AuthDb,
templateCache: templatecache.NewTemplateCache(testDB.SqlcClient, redis),
}

res := queryTemplateAliasForTeam(t, store, requesterTeamID, requesterTeamSlug, id.WithTag(alias, tt.tag))

require.Equal(t, http.StatusForbidden, res.StatusCode(),
"requester must get 403 regardless of tag presence on a foreign-owned template")
})
}
}

func TestQueryTaggedTemplateAlias(t *testing.T) {
t.Parallel()

tests := []struct {
name string
setupAlias func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string
wantStatus int
}{
{
name: "existing alias with ready tag returns ok",
setupAlias: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string {
t.Helper()
buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded")
testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "dev")

return id.WithTag(alias, "dev")
},
wantStatus: http.StatusOK,
},
{
name: "existing alias with missing tag returns not found",
setupAlias: func(_ *testing.T, _ context.Context, _ *testutils.Database, _ string, alias string) string {
return id.WithTag(alias, "missing")
},
wantStatus: http.StatusNotFound,
},
{
name: "existing alias with explicit default tag returns ok",
setupAlias: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string {
t.Helper()
buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded")
testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, id.DefaultTag)

return id.WithTag(alias, id.DefaultTag)
},
wantStatus: http.StatusOK,
},
{
name: "existing alias with missing explicit default tag returns not found",
setupAlias: func(_ *testing.T, _ context.Context, _ *testutils.Database, _ string, alias string) string {
return id.WithTag(alias, id.DefaultTag)
},
wantStatus: http.StatusNotFound,
},
{
name: "existing alias with non-ready tag returns not found",
setupAlias: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string {
t.Helper()
buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "waiting")
testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "dev")

return id.WithTag(alias, "dev")
},
wantStatus: http.StatusNotFound,
},
{
name: "existing alias with ready build id suffix returns ok",
setupAlias: func(t *testing.T, ctx context.Context, db *testutils.Database, templateID string, alias string) string {
t.Helper()
buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded")
testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, id.DefaultTag)

return id.WithTag(alias, buildID.String())
},
wantStatus: http.StatusOK,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

testDB := testutils.SetupDatabase(t)
redis := redis_utils.SetupInstance(t)
ctx := t.Context()

teamID := testutils.CreateTestTeam(t, testDB)
teamSlug := testutils.GetTeamSlug(t, ctx, testDB, teamID)
templateID := testutils.CreateTestTemplate(t, testDB, teamID)
alias := testutils.CreateTestTemplateAliasWithNamespace(t, testDB, templateID, &teamSlug)

store := &APIStore{
sqlcDB: testDB.SqlcClient,
authDB: testDB.AuthDb,
templateCache: templatecache.NewTemplateCache(testDB.SqlcClient, redis),
}

taggedAlias := tt.setupAlias(t, ctx, testDB, templateID, alias)
res := queryTemplateAliasForTeam(t, store, teamID, teamSlug, taggedAlias)

require.Equal(t, tt.wantStatus, res.StatusCode())
if tt.wantStatus != http.StatusOK {
return
}

require.NotNil(t, res.JSON200)
assert.Equal(t, templateID, res.JSON200.TemplateID)
})
}
}
Loading