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
133 changes: 133 additions & 0 deletions devspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
version: v2beta1

vars:
EXPOSE_NAMESPACE: platform

functions:
patch_deployment: |-
echo "Patching expose deployment for DevSpace..."
# Its own release names the deployment expose-expose, the umbrella
# names it expose. Look it up by label rather than guessing.
EXPOSE_DEPLOYMENT="$(kubectl get deployment -n ${EXPOSE_NAMESPACE} \
-l app.kubernetes.io/name=expose -o jsonpath='{.items[0].metadata.name}')"
kubectl patch deployment "${EXPOSE_DEPLOYMENT}" -n ${EXPOSE_NAMESPACE} --type strategic --patch "$(cat <<'EOF'
spec:
template:
spec:
securityContext:
runAsUser: 1000
fsGroup: 1000
initContainers: []
volumes:
- name: data
emptyDir: {}
containers:
- name: expose
image: ghcr.io/agynio/devcontainer-go:1
workingDir: /opt/app/data
command:
- sh
- -c
- |
set -eu
elapsed=0
while [ ! -f /opt/app/data/go.mod ]; do
sleep 1; elapsed=$((elapsed + 1))
[ "$elapsed" -ge 120 ] && { echo "ERROR: sync timeout" >&2; exit 1; }
done
# .gen survives restarts in the synced volume; regenerating on every
# crash-loop restart rate-limits us out of the BSR. The marker keys
# on the command, not the module, so delete the pod after an api
# publish to pick up new protos.
GEN="buf generate buf.build/agynio/api --include-imports --path agynio/api/expose/v1 --path agynio/api/runner/v1 --path agynio/api/ziti_management/v1 --path agynio/api/runners/v1 --path agynio/api/notifications/v1 --path agynio/api/identity/v1 --path agynio/api/authorization/v1 --path agynio/api/agents/v1 --path agynio/api/organizations/v1"
if [ "$(cat .gen/.gen-cmd 2>/dev/null)" != "$GEN" ]; then
rm -rf .gen
sh -c "$GEN"
printf '%s' "$GEN" > .gen/.gen-cmd
fi
exec go run ./cmd/expose-service
volumeMounts:
- name: data
mountPath: /opt/app/data
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 4Gi
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
readOnlyRootFilesystem: false
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
EOF
)"
# go run recompiles on every restart; the probes would kill the pod before
# the first build finishes. Nulling through a strategic merge tolerates
# them already being absent, so the patch stays re-runnable.
kubectl patch deployment "${EXPOSE_DEPLOYMENT}" -n ${EXPOSE_NAMESPACE} --type strategic --patch \
'{"spec":{"template":{"spec":{"containers":[{"name":"expose","livenessProbe":null,"readinessProbe":null}]}}}}'

pipelines:
dev:
flags:
- name: watch
short: w
description: "Keep DevSpace running and stream logs"
run: |-
patch_deployment
if [ "$(get_flag "watch")" == "true" ]; then
start_dev --disable-pod-replace expose
else
start_dev --disable-pod-replace expose
echo "Waiting for expose to be healthy on :50051..."
healthy=false
for i in $(seq 1 60); do
if exec_container --label-selector=app.kubernetes.io/name=expose -n ${EXPOSE_NAMESPACE} -- bash -c 'nc -z localhost 50051 >/dev/null 2>&1 || (echo > /dev/tcp/localhost/50051) >/dev/null 2>&1'; then
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: expose did not become healthy within 120s" >&2
exit 1
fi
echo "Dev environment ready. Stopping dev session."
stop_dev expose
fi

dev:
expose:
namespace: ${EXPOSE_NAMESPACE}
labelSelector:
app.kubernetes.io/name: expose
containers:
expose:
container: expose
sync:
- path: ./:/opt/app/data
excludePaths:
- .git/
- .devspace/
- /.gen/
- /tmp/
uploadExcludePaths:
- .git/
- .devspace/
- /.gen/
- /tmp/
downloadExcludePaths:
- .git/
- .devspace/
- /.gen/
- /tmp/
logs:
enabled: true
lastLines: 200
25 changes: 25 additions & 0 deletions internal/server/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ const (
clusterAdminObject = "cluster:global"
identityUserPrefix = "identity:"
organizationObjectPrefix = "organization:"
sandboxObjectPrefix = "sandbox:"
organizationOwnerRelation = "owner"
organizationMemberRelation = "member"
// The relation the Terminal Proxy checks to issue a shell ticket. A shell
// can run `agyn expose` itself, so managing the same ports through an API
// call is the same capability and takes the same relation.
sandboxCanConnectRelation = "can_connect"
)

type identityType string
Expand Down Expand Up @@ -124,6 +129,26 @@ func ensureIDMatch(expectedID, providedID, label string) error {
return nil
}

// checkSandboxRelation reports whether the caller holds a relation on a
// sandbox. Unlike requireOrgRelation it returns the decision rather than an
// error, because callers fall through to a second check when it is false.
func checkSandboxRelation(ctx context.Context, authz authorizationv1.AuthorizationServiceClient, identityID, sandboxID, relation string) (bool, error) {
if identityID == "" || sandboxID == "" {
return false, status.Error(codes.Internal, "identity or sandbox id missing for authorization check")
}
resp, err := authz.Check(ctx, &authorizationv1.CheckRequest{
TupleKey: &authorizationv1.TupleKey{
User: identityUserPrefix + identityID,
Relation: relation,
Object: sandboxObjectPrefix + sandboxID,
},
})
if err != nil {
return false, status.Errorf(codes.Internal, "authorization check failed: %v", err)
}
return resp.GetAllowed(), nil
}

func requireOrgRelation(ctx context.Context, authz authorizationv1.AuthorizationServiceClient, identityID, organizationID, relation string) error {
if identityID == "" || organizationID == "" {
return status.Error(codes.Internal, "identity or organization id missing for authorization check")
Expand Down
145 changes: 145 additions & 0 deletions internal/server/sandbox_exposures_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package server

import (
"context"
"testing"

authorizationv1 "github.com/agynio/expose/.gen/go/agynio/api/authorization/v1"
exposev1 "github.com/agynio/expose/.gen/go/agynio/api/expose/v1"
runnersv1 "github.com/agynio/expose/.gen/go/agynio/api/runners/v1"
zitimanagementv1 "github.com/agynio/expose/.gen/go/agynio/api/ziti_management/v1"
"github.com/agynio/expose/internal/store"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// relationAuthz allows exactly the (relation, object) pairs it is given and
// denies everything else, so a test can say which grant it is exercising.
func relationAuthz(allowed map[string]bool) *mockAuthz {
return &mockAuthz{check: func(_ context.Context, req *authorizationv1.CheckRequest) (*authorizationv1.CheckResponse, error) {
key := req.GetTupleKey().GetRelation() + " " + req.GetTupleKey().GetObject()
return &authorizationv1.CheckResponse{Allowed: allowed[key]}, nil
}}
}

func sandboxConnectAllowed(sandboxID uuid.UUID) *mockAuthz {
return relationAuthz(map[string]bool{
sandboxCanConnectRelation + " " + sandboxObjectPrefix + sandboxID.String(): true,
})
}

// A person who can open a shell can run `agyn expose add` in it, so the same
// action through the API takes the same relation.
func TestAddExposureAllowsSandboxConnector(t *testing.T) {
sandboxID, workloadID, orgID := uuid.New(), uuid.New(), uuid.New()
storeMock := provisioningStore()
svc := New(storeMock, provisioningZiti(),
workloadOwnedBy(runnersv1.RuntimeOwnerKind_RUNTIME_OWNER_KIND_SANDBOX, sandboxID, orgID),
sandboxConnectAllowed(sandboxID), sandboxNames("acme", "super-sandbox"))

// A browser is not the workload, so it names the workload explicitly.
resp, err := svc.AddExposure(contextWithIdentity(uuid.New().String(), string(identityTypeUser), ""),
&exposev1.AddExposureRequest{WorkloadId: workloadID.String(), Port: 3000})
if err != nil {
t.Fatalf("AddExposure: %v", err)
}
if want := "http://super-sandbox.acme.agyn:3000"; resp.GetExposure().GetUrl() != want {
t.Fatalf("url: got %q, want %q", resp.GetExposure().GetUrl(), want)
}
}

func TestRemoveExposureAllowsSandboxConnector(t *testing.T) {
sandboxID, workloadID, orgID := uuid.New(), uuid.New(), uuid.New()
exposure := store.Exposure{
ID: uuid.New(),
WorkloadID: workloadID,
OwnerKind: store.OwnerKindSandbox,
OwnerID: sandboxID,
OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true},
Port: 3000,
OpenZitiServiceID: "svc-id",
OpenZitiBindPolicyID: "bind-id",
OpenZitiDialPolicyID: "dial-id",
Hostname: "super-sandbox.acme.agyn",
URL: "http://super-sandbox.acme.agyn:3000",
Status: store.ExposureStatusActive,
}
deleted := 0
storeMock := &mockStore{
getExposureByWorkloadAndPort: func(context.Context, uuid.UUID, int32) (store.Exposure, error) {
return exposure, nil
},
updateExposureStatus: func(context.Context, uuid.UUID, store.ExposureStatus) error { return nil },
deleteExposure: func(context.Context, uuid.UUID) error { deleted++; return nil },
}
ziti := &mockZitiMgmt{
deleteServicePolicy: func(context.Context, *zitimanagementv1.DeleteServicePolicyRequest) (*zitimanagementv1.DeleteServicePolicyResponse, error) {
return &zitimanagementv1.DeleteServicePolicyResponse{}, nil
},
deleteService: func(context.Context, *zitimanagementv1.DeleteServiceRequest) (*zitimanagementv1.DeleteServiceResponse, error) {
return &zitimanagementv1.DeleteServiceResponse{}, nil
},
}

svc := New(storeMock, ziti,
workloadOwnedBy(runnersv1.RuntimeOwnerKind_RUNTIME_OWNER_KIND_SANDBOX, sandboxID, orgID),
sandboxConnectAllowed(sandboxID), sandboxNames("acme", "super-sandbox"))

if _, err := svc.RemoveExposure(contextWithIdentity(uuid.New().String(), string(identityTypeUser), ""),
&exposev1.RemoveExposureRequest{WorkloadId: workloadID.String(), Port: 3000}); err != nil {
t.Fatalf("RemoveExposure: %v", err)
}
if deleted != 1 {
t.Fatalf("expected the exposure to be deleted once, got %d", deleted)
}
}

// A member of the organization who is neither owner nor collaborator holds no
// relation on the sandbox and is refused.
func TestAddExposureRefusesANonConnector(t *testing.T) {
sandboxID, workloadID, orgID := uuid.New(), uuid.New(), uuid.New()
storeMock := &mockStore{createExposure: func(context.Context, store.Exposure) error {
t.Fatal("did not expect an exposure to be created")
return nil
}}
svc := New(storeMock, &mockZitiMgmt{},
workloadOwnedBy(runnersv1.RuntimeOwnerKind_RUNTIME_OWNER_KIND_SANDBOX, sandboxID, orgID),
relationAuthz(map[string]bool{}), sandboxNames("acme", "super-sandbox"))

_, err := svc.AddExposure(contextWithIdentity(uuid.New().String(), string(identityTypeUser), ""),
&exposev1.AddExposureRequest{WorkloadId: workloadID.String(), Port: 3000})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("expected permission denied, got %v", err)
}
}

// can_connect on one sandbox says nothing about another.
func TestAddExposureRefusesAConnectorOfAnotherSandbox(t *testing.T) {
sandboxID, workloadID, orgID := uuid.New(), uuid.New(), uuid.New()
svc := New(&mockStore{}, &mockZitiMgmt{},
workloadOwnedBy(runnersv1.RuntimeOwnerKind_RUNTIME_OWNER_KIND_SANDBOX, sandboxID, orgID),
sandboxConnectAllowed(uuid.New()), sandboxNames("acme", "super-sandbox"))

_, err := svc.AddExposure(contextWithIdentity(uuid.New().String(), string(identityTypeUser), ""),
&exposev1.AddExposureRequest{WorkloadId: workloadID.String(), Port: 3000})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("expected permission denied, got %v", err)
}
}

// The sandbox clause does not reach agent-instance workloads: they have no
// comparable relation, so the explicit path stays cluster-admin-only.
func TestAddExposureIgnoresSandboxRelationForAgentWorkloads(t *testing.T) {
instanceID, workloadID, orgID := uuid.New(), uuid.New(), uuid.New()
svc := New(&mockStore{}, &mockZitiMgmt{},
workloadOwnedBy(runnersv1.RuntimeOwnerKind_RUNTIME_OWNER_KIND_AGENT_INSTANCE, instanceID, orgID),
// can_connect on an object that happens to share the owner's id.
sandboxConnectAllowed(instanceID), instanceNames("acme", "bob", "research"))

_, err := svc.AddExposure(contextWithIdentity(uuid.New().String(), string(identityTypeUser), ""),
&exposev1.AddExposureRequest{WorkloadId: workloadID.String(), Port: 3000})
if status.Code(err) != codes.PermissionDenied {
t.Fatalf("expected permission denied, got %v", err)
}
}
35 changes: 31 additions & 4 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,6 @@ func (s *Server) AddExposure(ctx context.Context, req *exposev1.AddExposureReque
explicitWorkloadID := strings.TrimSpace(req.GetWorkloadId())
var workloadID uuid.UUID
if explicitWorkloadID != "" {
if err := requireClusterAdmin(ctx, s.authz, caller.identity.identityID); err != nil {
return nil, err
}
parsedWorkloadID, err := parseUUID(explicitWorkloadID, "workload_id")
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
Expand Down Expand Up @@ -148,6 +145,8 @@ func (s *Server) AddExposure(ctx context.Context, req *exposev1.AddExposureReque
if err := requireWorkloadSelf(caller, owner.OwnerID); err != nil {
return nil, err
}
} else if err := s.requireExposureMutation(ctx, caller, owner); err != nil {
return nil, err
}

// Every exposure on one entity shares a hostname and differs only in port,
Expand Down Expand Up @@ -288,7 +287,7 @@ func (s *Server) RemoveExposure(ctx context.Context, req *exposev1.RemoveExposur
return nil, err
}
if !allowed {
if err := requireOrgRelation(ctx, s.authz, caller.identity.identityID, owner.OrganizationID.UUID.String(), organizationOwnerRelation); err != nil {
if err := s.requireExposureMutation(ctx, caller, owner); err != nil {
return nil, err
}
}
Expand Down Expand Up @@ -359,6 +358,34 @@ func (s *Server) ListExposures(ctx context.Context, req *exposev1.ListExposuresR
return &exposev1.ListExposuresResponse{Exposures: items, NextPageToken: nextToken}, nil
}

// requireExposureMutation authorizes a caller who is not the workload to add or
// remove one of its exposures.
//
// A sandbox's ports belong to whoever can open a shell in it: a shell can run
// `agyn expose add` already, so a button that does the same confers nothing new,
// and a stricter gate would refuse someone the terminal in the next tab obeys.
// An agent-instance workload has no comparable relation, so it stays
// cluster-admin-only.
func (s *Server) requireExposureMutation(ctx context.Context, caller exposureCaller, owner store.ExposureOwner) error {
if owner.OwnerKind == store.OwnerKindSandbox {
allowed, err := checkSandboxRelation(ctx, s.authz, caller.identity.identityID, owner.OwnerID.String(), sandboxCanConnectRelation)
if err != nil {
return err
}
if allowed {
return nil
}
}
// An organization owner keeps the reach they had over an agent workload's
// exposures; for a sandbox this is the fallback after can_connect.
if owner.OrganizationID.Valid {
if err := requireOrgRelation(ctx, s.authz, caller.identity.identityID, owner.OrganizationID.UUID.String(), organizationOwnerRelation); err == nil {
return nil
}
}
return requireClusterAdmin(ctx, s.authz, caller.identity.identityID)
}

func (s *Server) handleProvisioningFailure(ctx context.Context, exposureID uuid.UUID, resources store.ExposureResourceIDs) {
remaining := resources
cleanupFailed := false
Expand Down
Loading
Loading