From cdbe3453d3b1222375a35551dd41b00e352a42e5 Mon Sep 17 00:00:00 2001 From: BillyJBryant <3013565+billyjbryant@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:53:15 -0700 Subject: [PATCH 1/7] revert: restore CommonFate integrations removed in b0e4767 This reverts commit b0e4767 to restore AccessRequestHook, cfregistry, proxy, eks, rds, and auth packages. These will be generalized to be provider-agnostic in subsequent commits. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/granted/main.go | 5 + go.mod | 82 +- go.sum | 289 ++++++- pkg/accessrequest/role.go | 121 +++ pkg/assume/assume.go | 126 ++- pkg/assume/entrypoint.go | 4 + pkg/cfcfg/cfcfg.go | 82 ++ pkg/granted/auth/auth.go | 55 ++ pkg/granted/cf.go | 97 +++ pkg/granted/credential_process.go | 77 +- pkg/granted/eks/config.go | 93 +++ pkg/granted/eks/eks.go | 169 ++++ pkg/granted/entrypoint.go | 40 + pkg/granted/exp/exp.go | 16 + pkg/granted/exp/request/request.go | 758 ++++++++++++++++++ pkg/granted/proxy/ensureaccess.go | 127 +++ pkg/granted/proxy/initiateconnection.go | 55 ++ pkg/granted/proxy/listenandproxy.go | 99 +++ pkg/granted/proxy/ports.go | 37 + pkg/granted/proxy/prompt.go | 83 ++ pkg/granted/proxy/proxy.go | 160 ++++ pkg/granted/proxy/ssm_logger.go | 101 +++ pkg/granted/proxy/writers.go | 30 + pkg/granted/rds/local_port.go | 31 + pkg/granted/rds/local_port_test.go | 56 ++ pkg/granted/rds/rds.go | 207 +++++ pkg/granted/registry/add.go | 201 +++-- pkg/granted/registry/cfregistry/cfregistry.go | 156 ++++ pkg/granted/registry/registry.go | 12 + pkg/granted/request/check.go | 101 +++ pkg/granted/request/close.go | 201 +++++ pkg/granted/request/request.go | 122 +++ pkg/granted/sso.go | 57 +- .../accessrequesthook/accessrequesthook.go | 549 +++++++++++++ 34 files changed, 4319 insertions(+), 80 deletions(-) create mode 100644 pkg/accessrequest/role.go create mode 100644 pkg/cfcfg/cfcfg.go create mode 100644 pkg/granted/auth/auth.go create mode 100644 pkg/granted/cf.go create mode 100644 pkg/granted/eks/config.go create mode 100644 pkg/granted/eks/eks.go create mode 100644 pkg/granted/exp/exp.go create mode 100644 pkg/granted/exp/request/request.go create mode 100644 pkg/granted/proxy/ensureaccess.go create mode 100644 pkg/granted/proxy/initiateconnection.go create mode 100644 pkg/granted/proxy/listenandproxy.go create mode 100644 pkg/granted/proxy/ports.go create mode 100644 pkg/granted/proxy/prompt.go create mode 100644 pkg/granted/proxy/proxy.go create mode 100644 pkg/granted/proxy/ssm_logger.go create mode 100644 pkg/granted/proxy/writers.go create mode 100644 pkg/granted/rds/local_port.go create mode 100644 pkg/granted/rds/local_port_test.go create mode 100644 pkg/granted/rds/rds.go create mode 100644 pkg/granted/registry/cfregistry/cfregistry.go create mode 100644 pkg/granted/request/check.go create mode 100644 pkg/granted/request/close.go create mode 100644 pkg/granted/request/request.go create mode 100644 pkg/hook/accessrequesthook/accessrequesthook.go diff --git a/cmd/granted/main.go b/cmd/granted/main.go index 542602a7..ceec6041 100644 --- a/cmd/granted/main.go +++ b/cmd/granted/main.go @@ -10,12 +10,17 @@ import ( "github.com/common-fate/clio" "github.com/common-fate/clio/clierr" + "github.com/common-fate/updatecheck" + "github.com/fwdcloudsec/granted/internal/build" "github.com/fwdcloudsec/granted/pkg/assume" "github.com/fwdcloudsec/granted/pkg/granted" "github.com/urfave/cli/v2" ) func main() { + updatecheck.Check(updatecheck.GrantedCLI, build.Version, !build.IsDev()) + defer updatecheck.Print() + c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) go func() { diff --git a/go.mod b/go.mod index 3c999865..198e1196 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 + github.com/common-fate/updatecheck v0.3.5 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/errors v0.9.1 github.com/segmentio/ksuid v1.0.4 @@ -17,44 +18,104 @@ require ( ) require ( + connectrpc.com/connect v1.14.0 github.com/alessio/shellescape v1.4.2 + github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 + github.com/aws/session-manager-plugin v0.0.0-20240702185740-6384c679ead7 + github.com/briandowns/spinner v1.23.0 + github.com/charmbracelet/lipgloss v0.13.0 + github.com/common-fate/cli v1.8.0 github.com/common-fate/clio v1.2.3 + github.com/common-fate/common-fate v0.15.13 + github.com/common-fate/glide-cli v0.6.0 github.com/common-fate/grab v1.3.0 + github.com/common-fate/sdk v1.71.0 + github.com/common-fate/xid v1.0.0 github.com/fatih/color v1.16.0 + github.com/google/uuid v1.6.0 github.com/hashicorp/go-version v1.7.0 + github.com/hashicorp/yamux v0.1.2 + github.com/lithammer/fuzzysearch v1.1.5 + github.com/mattn/go-runewidth v0.0.16 github.com/schollz/progressbar/v3 v3.13.1 go.uber.org/zap v1.26.0 + google.golang.org/protobuf v1.34.2 gopkg.in/yaml.v3 v3.0.1 + k8s.io/client-go v0.28.4 ) require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect + github.com/aws/aws-sdk-go v1.54.19 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/benbjohnson/clock v1.3.5 // indirect + github.com/charmbracelet/x/ansi v0.2.3 // indirect + github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect + github.com/common-fate/iso8601 v1.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/deepmap/oapi-codegen v1.11.0 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/getkin/kin-openapi v0.107.0 // indirect + github.com/go-chi/chi/v5 v5.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/swag v0.22.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.11 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/invopop/yaml v0.2.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect + github.com/muhlemmer/gu v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.3.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/twinj/uuid v0.0.0-20151029044442-89173bcdda19 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - go.uber.org/atomic v1.9.0 // indirect + github.com/xtaci/smux v1.5.24 // indirect + github.com/zitadel/logging v0.6.0 // indirect + github.com/zitadel/oidc/v3 v3.26.0 // indirect + github.com/zitadel/schema v1.3.0 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.48.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/time v0.3.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) require ( @@ -69,6 +130,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect github.com/aws/smithy-go v1.24.1 github.com/common-fate/awsconfigfile v0.10.0 + github.com/common-fate/useragent v0.1.0 github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/danieljoos/wincred v1.1.2 // indirect github.com/dvsekhvalnov/jose2go v1.8.0 // indirect @@ -79,11 +141,12 @@ require ( github.com/joho/godotenv v1.4.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.20 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/olekukonko/tablewriter v0.0.5 github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sethvargo/go-retry v0.2.4 github.com/stretchr/testify v1.10.0 go.uber.org/ratelimit v0.3.0 golang.org/x/sync v0.19.0 @@ -91,4 +154,7 @@ require ( golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 gopkg.in/ini.v1 v1.67.0 + k8s.io/apimachinery v0.31.1 // indirect ) + +replace github.com/aws/session-manager-plugin => github.com/common-fate/session-manager-plugin v0.0.0-20240723053832-3d311db99016 diff --git a/go.sum b/go.sum index 3751100a..66ed9380 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +connectrpc.com/connect v1.14.0 h1:PDS+J7uoz5Oui2VEOMcfz6Qft7opQM9hPiKvtGC01pA= +connectrpc.com/connect v1.14.0/go.mod h1:uoAq5bmhhn43TwhaKdGKN/bZcGtzPW1v+ngDTn5u+8s= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= @@ -16,6 +18,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/aws/aws-sdk-go v1.54.19 h1:tyWV+07jagrNiCcGRzRhdtVjQs7Vy41NwsuOcl0IbVI= +github.com/aws/aws-sdk-go v1.54.19/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= @@ -36,6 +40,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1x github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w= github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= @@ -44,66 +50,210 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= +github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= +github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= +github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= +github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= +github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 h1:kHaBemcxl8o/pQ5VM1c8PVE1PubbNx3mjUr09OqWGCs= +github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575/go.mod h1:9d6lWj8KzO/fd/NrVaLscBKmPigpZpn5YawRPw+e3Yo= github.com/common-fate/awsconfigfile v0.10.0 h1:9W0JTeO0d3jNLw3Ps9U7IJwLYp4D9zcipq/sqNEWJOg= github.com/common-fate/awsconfigfile v0.10.0/go.mod h1:znstvN26aO+KUwmdjwZ+WcmitZ7heEJb5iFdCPokAO8= +github.com/common-fate/cli v1.8.0 h1:T3I+NCMTyvIlZC8QK9qfmsZWj3eSDSZRPHQlM5KJ8Q4= +github.com/common-fate/cli v1.8.0/go.mod h1:fE4jNXj30AvqFvBMTHIDuoI/IahN1h8iRrjEE2n2Td0= github.com/common-fate/clio v1.2.3 h1:hHwUYZjn66qGYDpgANl0EB/92hyi/Jsnd07qB09rvn4= github.com/common-fate/clio v1.2.3/go.mod h1:NkozaS15SA+6Y9zb+82eIj1i41aWShorTqA01GKQ7A8= +github.com/common-fate/common-fate v0.15.13 h1:7u4ik6yaodyClAx4J/HTY8neJC06h9QquLtYgYNFuuU= +github.com/common-fate/common-fate v0.15.13/go.mod h1:VttFtdUzSEPLU5BTnePaGae99+Q6OKjYvY22EcSLyQ0= +github.com/common-fate/glide-cli v0.6.0 h1:MYnODLkK2KthskUNbo6ir7y3xMFGQD9eHvFlBMIWQ/k= +github.com/common-fate/glide-cli v0.6.0/go.mod h1:ddp1UKGg0evzweWP2yVif3KTO19JWXg5+LjjmtpeE0U= github.com/common-fate/grab v1.3.0 h1:vGNBMfhAVAWtrLuH1stnhL4LsDb73drhegC/060q+Ok= github.com/common-fate/grab v1.3.0/go.mod h1:6zH8GckZGFrOKfZzL4Y/2OTvxwFeL6cDtsztM0GGC2Y= +github.com/common-fate/iso8601 v1.1.0 h1:nrej9shsK1aB4IyOAjZl68xGk8yDuUxVwQjoDzxSK2c= +github.com/common-fate/iso8601 v1.1.0/go.mod h1:DU4mvUEkkWZUUSJq2aCuNqM1luSb0Pwyb2dLzXS+img= +github.com/common-fate/sdk v1.71.0 h1:SA+KZdbkOWBR6SrTculoUlALAGj6ftULdUPgr3Yw7RY= +github.com/common-fate/sdk v1.71.0/go.mod h1:OrXhzB2Y1JSrKGHrb4qRmY+6MF2M3MFb+3edBnessXo= +github.com/common-fate/session-manager-plugin v0.0.0-20240723053832-3d311db99016 h1:WObxQKT/BuR8HWKSGsJ6aQb/cdhvkenkb1KWXNyPWeE= +github.com/common-fate/session-manager-plugin v0.0.0-20240723053832-3d311db99016/go.mod h1:glAZTUB+4Eg0JVLC3B/YEomJv6QHcNS3klJjw9HC5Y8= +github.com/common-fate/updatecheck v0.3.5 h1:UGIKMnYwuHjbhhCaisLz1pNPg8Z1nXEoWcfqT+4LkAg= +github.com/common-fate/updatecheck v0.3.5/go.mod h1:fru9yoUXmM3QVAUdDDqKQeDoln20Pkji/7EH64gVHMs= +github.com/common-fate/useragent v0.1.0 h1:RLmkIiJXcOUJAUyXWc/zCaGbrGmlCbHBGMx99ztQ3ZU= +github.com/common-fate/useragent v0.1.0/go.mod h1:GjXGR6cDiMboDP04qlfDfA5HTbeoRSoNgQWDAyOdW9o= +github.com/common-fate/xid v1.0.0 h1:G1goIvujOPfeuH7p7ibvu585QE10vHsjka8YjD8Qd1o= +github.com/common-fate/xid v1.0.0/go.mod h1:F4G+xicQxSTFfsVzbqopLRyxWmGswnbmODvmDo4Jivo= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/deepmap/oapi-codegen v1.11.0 h1:f/X2NdIkaBKsSdpeuwLnY/vDI0AtPUrmB5LMgc7YD+A= +github.com/deepmap/oapi-codegen v1.11.0/go.mod h1:k+ujhoQGxmQYBZBbxhOZNZf4j08qv5mC+OH+fFTnKxM= github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA= github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= +github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/getkin/kin-openapi v0.94.0/go.mod h1:LWZfzOd7PRy8GJ1dJ6mCU6tNdSfOwRac1BUPam4aw6Q= +github.com/getkin/kin-openapi v0.107.0 h1:bxhL6QArW7BXQj8NjXfIJQy680NsMKd25nwhvpCXchg= +github.com/getkin/kin-openapi v0.107.0/go.mod h1:9Dhr+FasATJZjS4iOLvB0hkaxgYdulrNYm2e9epLWOo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= +github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= +github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc= +github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.7.2/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks= +github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= +github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= +github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx v1.2.24/go.mod h1:zoNuZymNl5lgdcu6P7K6ie2QRll5HVfF4xwxBBK1NxY= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lithammer/fuzzysearch v1.1.5 h1:Ag7aKU08wp0R9QCfF4GoGST9HbmAIeLP7xwMrOBEp1c= +github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matryer/moq v0.2.7/go.mod h1:kITsx543GOENm48TUAQyJ9+SAvFSr7iGQXPoth/VUBk= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -121,8 +271,24 @@ github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMK github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= +github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= @@ -137,34 +303,76 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= +github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= +github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= +github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twinj/uuid v0.0.0-20151029044442-89173bcdda19 h1:HlxV0XiEKMMyjS3gGtJmmFZsxQ22GsLvA7F980il+1w= +github.com/twinj/uuid v0.0.0-20151029044442-89173bcdda19/go.mod h1:mMgcE1RHFUFqe5AfiwlINXisXfDGro23fWdPUfOMjRY= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/xtaci/smux v1.5.24 h1:77emW9dtnOxxOQ5ltR+8BbsX1kzcOxQ5gB+aaV9hXOY= +github.com/xtaci/smux v1.5.24/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank= +github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= +github.com/zitadel/oidc/v3 v3.26.0 h1:BG3OUK+JpuKz7YHJIyUxL5Sl2JV6ePkG42UP4Xv3J2w= +github.com/zitadel/oidc/v3 v3.26.0/go.mod h1:Cx6AYPTJO5q2mjqF3jaknbKOUjpq1Xui0SYvVhkKuXU= +github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= +github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= @@ -176,26 +384,60 @@ go.uber.org/ratelimit v0.3.0/go.mod h1:So5LG7CV1zWpY1sHe+DXTJqQvOx+FFPFaAs2SnoyB go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220513224357-95641704303c/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -209,27 +451,72 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.28.4 h1:8ZBrLjwosLl/NYgv1P7EQLqoO8MGQApnbgH8tu3BMzY= +k8s.io/api v0.28.4/go.mod h1:axWTGrY88s/5YE+JSt4uUi6NMM+gur1en2REMR7IRj0= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/client-go v0.28.4 h1:Np5ocjlZcTrkyRJ3+T3PkXDpe4UpatQxj85+xjaD2wY= +k8s.io/client-go v0.28.4/go.mod h1:0VDZFpgoZfelyP5Wqu0/r/TRYcLYuJ2U1KEeoaPa1N4= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/pkg/accessrequest/role.go b/pkg/accessrequest/role.go new file mode 100644 index 00000000..01ad8d7f --- /dev/null +++ b/pkg/accessrequest/role.go @@ -0,0 +1,121 @@ +// Package accessrequest handles +// making requests to roles that a +// user doesn't have access to. +package accessrequest + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/common-fate/clio/clierr" + "github.com/fwdcloudsec/granted/pkg/config" +) + +type Role struct { + Account string `json:"account"` + Role string `json:"role"` +} + +func (r Role) URL(dashboardURL string) string { + u, err := url.Parse(dashboardURL) + if err != nil { + return fmt.Sprintf("error building access request URL: %s", err.Error()) + } + u.Path = "access" + q := u.Query() + q.Add("type", "commonfate/aws-sso") + q.Add("permissionSetArn.label", r.Role) + q.Add("accountId", r.Account) + u.RawQuery = q.Encode() + + return u.String() +} + +func (r Role) Save() error { + roleBytes, err := json.Marshal(r) + if err != nil { + return err + } + + configFolder, err := config.GrantedConfigFolder() + if err != nil { + return err + } + + file := filepath.Join(configFolder, "latest-role") + return os.WriteFile(file, roleBytes, 0644) +} + +func LatestRole() (*Role, error) { + configFolder, err := config.GrantedConfigFolder() + if err != nil { + return nil, err + } + + file := filepath.Join(configFolder, "latest-role") + + if _, err := os.Stat(file); os.IsNotExist(err) { + return nil, clierr.New("no latest role saved", clierr.Info("You can run 'assume' to try and access a role. If the role is inaccessible it will be saved as the latest role.")) + } + + roleBytes, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var r Role + err = json.Unmarshal(roleBytes, &r) + if err != nil { + return nil, err + } + + return &r, nil +} + +type Profile struct { + Name string +} + +func (p Profile) Save() error { + profileBytes, err := json.Marshal(p) + if err != nil { + return err + } + + configFolder, err := config.GrantedConfigFolder() + if err != nil { + return err + } + + file := filepath.Join(configFolder, "latest-profile") + return os.WriteFile(file, profileBytes, 0644) +} + +func LatestProfile() (*Profile, error) { + configFolder, err := config.GrantedConfigFolder() + if err != nil { + return nil, err + } + + file := filepath.Join(configFolder, "latest-profile") + + if _, err := os.Stat(file); os.IsNotExist(err) { + return nil, clierr.New("no latest profile saved", clierr.Info("You can run 'assume' to try and access a profile. If the profile is inaccessible it will be saved as the latest profile.")) + } + + profileBytes, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var p Profile + err = json.Unmarshal(profileBytes, &p) + if err != nil { + return nil, err + } + + return &p, nil +} diff --git a/pkg/assume/assume.go b/pkg/assume/assume.go index 09717345..f9ace849 100644 --- a/pkg/assume/assume.go +++ b/pkg/assume/assume.go @@ -1,6 +1,7 @@ package assume import ( + "context" "errors" "fmt" "net/url" @@ -27,12 +28,15 @@ import ( "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/console" "github.com/fwdcloudsec/granted/pkg/forkprocess" + "github.com/fwdcloudsec/granted/pkg/hook/accessrequesthook" "github.com/fwdcloudsec/granted/pkg/launcher" "github.com/fwdcloudsec/granted/pkg/testable" cfflags "github.com/fwdcloudsec/granted/pkg/urfav_overrides" "github.com/fatih/color" "github.com/hako/durafmt" + sethRetry "github.com/sethvargo/go-retry" "github.com/urfave/cli/v2" + durationpb "google.golang.org/protobuf/types/known/durationpb" "gopkg.in/ini.v1" ) @@ -301,6 +305,8 @@ func AssumeCommand(c *cli.Context) error { configOpts.Duration = d } + reason := assumeFlags.String("reason") + attachments := assumeFlags.StringSlice("attach") cfg, err := config.Load() if err != nil { return err @@ -308,6 +314,13 @@ func AssumeCommand(c *cli.Context) error { configOpts.UseAuthorizationCode = assumeFlags.Bool("use-authorization-code") || cfg.UseAuthorizationCode + wait := assumeFlags.Bool("wait") + retryDuration := time.Minute * 1 + if wait { + //if wait is specified, increase the timeout to 15 minutes. + retryDuration = time.Minute * 15 + } + // if getConsoleURL is true, we'll use the AWS federated login to retrieve a URL to access the console. // depending on how Granted is configured, this is then printed to the terminal or a browser is launched at the URL automatically. getConsoleURL := !assumeFlags.Bool("env") && ((assumeFlags.Bool("console") || assumeFlags.String("console-destination") != "") || assumeFlags.Bool("active-role") || assumeFlags.String("service") != "" || assumeFlags.Bool("url")) @@ -328,7 +341,62 @@ func AssumeCommand(c *cli.Context) error { creds, err := profile.AssumeConsole(c.Context, configOpts) if err != nil && strings.HasPrefix(err.Error(), "no access") { clio.Debugw("received a No Access error", "error", err) - // TODO: this is where we can add a hook in future to allow users to define a shell script to be executed to automatically request access, etc. + hook := accessrequesthook.Hook{} + + var apiDuration *durationpb.Duration + if duration != "" { + d, err := time.ParseDuration(duration) + if err != nil { + return err + } + apiDuration = durationpb.New(d) + } + + noAccessInput := accessrequesthook.NoAccessInput{ + Profile: profile, + Reason: reason, + Attachments: attachments, + Duration: apiDuration, + Confirm: assumeFlags.Bool("confirm"), + Wait: wait, + StartTime: time.Now(), + } + retry, justActivated, hookErr := hook.NoAccess(c.Context, noAccessInput) + if hookErr != nil { + return hookErr + } + + if retry { + // reset the start time for the timer (otherwise it shows 2s, 7s, 12s etc) + noAccessInput.StartTime = time.Now() + + b := sethRetry.NewConstant(5 * time.Second) + b = sethRetry.WithMaxDuration(retryDuration, b) + err = sethRetry.Do(c.Context, b, func(ctx context.Context) (err error) { + + if !justActivated { + //also proactively check if request has been approved and attempt to activate + err = hook.RetryAccess(ctx, noAccessInput) + if err != nil { + return sethRetry.RetryableError(err) + } + } + + creds, err = profile.AssumeConsole(c.Context, configOpts) + if err != nil { + return sethRetry.RetryableError(err) + } + + // If we successfully got credentials, mark as just activated + justActivated = true + + return nil + }) + if err != nil { + return err + } + + } } if err != nil { @@ -439,7 +507,61 @@ func AssumeCommand(c *cli.Context) error { creds, err := profile.AssumeTerminal(c.Context, configOpts) if err != nil && strings.HasPrefix(err.Error(), "no access") { clio.Debugw("received a No Access error", "error", err) - // TODO: this is where we can add a hook in future to allow users to define a shell script to be executed to automatically request access, etc. + hook := accessrequesthook.Hook{} + + var apiDuration *durationpb.Duration + if duration != "" { + d, err := time.ParseDuration(duration) + if err != nil { + return err + } + apiDuration = durationpb.New(d) + } + noAccessInput := accessrequesthook.NoAccessInput{ + Profile: profile, + Reason: reason, + Duration: apiDuration, + Confirm: assumeFlags.Bool("confirm"), + Wait: wait, + StartTime: time.Now(), + } + retry, justActivated, hookErr := hook.NoAccess(c.Context, noAccessInput) + if hookErr != nil { + return hookErr + } + + if retry { + // reset the start time for the timer (otherwise it shows 2s, 7s, 12s etc) + noAccessInput.StartTime = time.Now() + + b := sethRetry.NewConstant(time.Second * 5) + b = sethRetry.WithMaxDuration(retryDuration, b) + err = sethRetry.Do(c.Context, b, func(ctx context.Context) (err error) { + + if !justActivated { + //also proactively check if request has been approved and attempt to activate + err = hook.RetryAccess(ctx, noAccessInput) + if err != nil { + return sethRetry.RetryableError(err) + } + } + + creds, err = profile.AssumeTerminal(c.Context, configOpts) + if err != nil { + + return sethRetry.RetryableError(err) + } + + // If we successfully got credentials, mark as just activated + justActivated = true + + return nil + }) + if err != nil { + return err + } + + } } if err != nil { diff --git a/pkg/assume/entrypoint.go b/pkg/assume/entrypoint.go index 92f939aa..b9829294 100644 --- a/pkg/assume/entrypoint.go +++ b/pkg/assume/entrypoint.go @@ -7,6 +7,7 @@ import ( "github.com/common-fate/clio" "github.com/common-fate/clio/cliolog" + "github.com/common-fate/useragent" "github.com/fwdcloudsec/granted/internal/build" "github.com/fwdcloudsec/granted/pkg/alias" "github.com/fwdcloudsec/granted/pkg/assumeprint" @@ -156,6 +157,9 @@ func GetCliApp() *cli.App { return alias.MustBeConfigured(c.Bool("auto-configure-shell")) } + // set the user agent + c.Context = useragent.NewContext(c.Context, "granted", build.Version) + return nil }, } diff --git a/pkg/cfcfg/cfcfg.go b/pkg/cfcfg/cfcfg.go new file mode 100644 index 00000000..d840b09d --- /dev/null +++ b/pkg/cfcfg/cfcfg.go @@ -0,0 +1,82 @@ +package cfcfg + +import ( + "context" + "fmt" + "net/url" + + "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/cfaws" + sdkconfig "github.com/common-fate/sdk/config" +) + +func GetCommonFateURL(profile *cfaws.Profile) (*url.URL, error) { + if profile == nil { + clio.Debugw("skipping loading Common Fate SDK from URL", "reason", "profile was nil") + return nil, nil + } + if profile.RawConfig == nil { + clio.Debugw("skipping loading Common Fate SDK from URL", "reason", "profile.RawConfig was nil") + return nil, nil + } + if !profile.RawConfig.HasKey("common_fate_url") { + clio.Debugw("skipping loading Common Fate SDK from URL", "reason", "profile does not have key common_fate_url", "profile_keys", profile.RawConfig.KeyStrings()) + return nil, nil + } + key, err := profile.RawConfig.GetKey("common_fate_url") + if err != nil { + return nil, err + } + + u, err := url.Parse(key.Value()) + if err != nil { + return nil, fmt.Errorf("invalid common_fate_url (%s): %w", key.Value(), err) + } + + return u, nil +} + +func GenerateRequestURL(apiURL string, requestID string) (string, error) { + u, err := url.Parse(apiURL) + if err != nil { + return "", err + } + p := u.JoinPath("access", "requests", requestID) + return p.String(), nil +} + +func Load(ctx context.Context, profile *cfaws.Profile) (*sdkconfig.Context, error) { + cfURL, err := GetCommonFateURL(profile) + if err != nil { + return nil, err + } + + if cfURL != nil { + cfURL = cfURL.JoinPath("config.json") + + clio.Debugw("configuring Common Fate SDK from URL", "url", cfURL.String()) + + return sdkconfig.New(ctx, sdkconfig.Opts{ + ConfigSources: []string{cfURL.String()}, + }) + } else { + // if we can't load the Common Fate SDK config (e.g. if `~/.cf/config` is not present) + // we can't request access through the Common Fate platform. + return sdkconfig.LoadDefault(ctx) + + } +} + +func LoadURL(ctx context.Context, cfURL string) (*sdkconfig.Context, error) { + u, err := url.Parse(cfURL) + if err != nil { + return nil, err + } + u = u.JoinPath("config.json") + + clio.Debugw("configuring Common Fate SDK from URL", "url", u.String()) + + return sdkconfig.New(ctx, sdkconfig.Opts{ + ConfigSources: []string{u.String()}, + }) +} diff --git a/pkg/granted/auth/auth.go b/pkg/granted/auth/auth.go new file mode 100644 index 00000000..1b7b855a --- /dev/null +++ b/pkg/granted/auth/auth.go @@ -0,0 +1,55 @@ +package auth + +import ( + "github.com/common-fate/cli/cmd/cli/command" + "github.com/common-fate/clio" + "github.com/common-fate/sdk/config" + "github.com/common-fate/sdk/loginflow" + "github.com/urfave/cli/v2" +) + +var Command = cli.Command{ + Name: "auth", + Usage: "Manage OIDC authentication for Granted", + Flags: []cli.Flag{}, + Subcommands: []*cli.Command{ + &command.Configure, + &loginCommand, + &logoutCommand, + &command.Context, + }, +} + +var loginCommand = cli.Command{ + Name: "login", + Usage: "Authenticate to an OIDC provider", + Action: func(c *cli.Context) error { + cfg, err := config.LoadDefault(c.Context) + if err == config.ErrConfigFileNotFound { + clio.Errorf("The Common Fate config file (~/.cf/config by default) was not found. To fix this, run 'granted auth configure https://commonfate.example.com' (replacing the URL in the command with your Common Fate deployment URL") + } + if err != nil { + return err + } + + lf := loginflow.NewFromConfig(cfg) + + return lf.Login(c.Context) + }, +} + +var logoutCommand = cli.Command{ + Name: "logout", + Usage: "Log out of an OIDC provider", + Action: func(c *cli.Context) error { + cfg, err := config.LoadDefault(c.Context) + if err == config.ErrConfigFileNotFound { + clio.Errorf("The Common Fate config file (~/.cf/config by default) was not found. To fix this, run 'granted auth configure https://commonfate.example.com' (replacing the URL in the command with your Common Fate deployment URL") + } + if err != nil { + return err + } + + return cfg.TokenStore.Clear() + }, +} diff --git a/pkg/granted/cf.go b/pkg/granted/cf.go new file mode 100644 index 00000000..3ccd4a64 --- /dev/null +++ b/pkg/granted/cf.go @@ -0,0 +1,97 @@ +package granted + +import ( + "errors" + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/cfaws" + "github.com/fwdcloudsec/granted/pkg/cfcfg" + sdkconfig "github.com/common-fate/sdk/config" + "github.com/pkg/browser" + "github.com/urfave/cli/v2" +) + +var CFCommand = cli.Command{ + Name: "common-fate", + Aliases: []string{"cf"}, + Usage: "Interact with your Common Fate deployment", + Subcommands: []*cli.Command{&ConsoleCommand}, +} + +var CFConsoleCommand = cli.Command{ + Name: "console", + Usage: "Open the Common Fate web console", + Flags: []cli.Flag{&cli.StringFlag{Name: "profile", Usage: "Open the Common Fate web console for a specific profile"}}, + Action: func(c *cli.Context) error { + + ctx := c.Context + consoleURL := "" + profiles, err := cfaws.LoadProfiles() + if err != nil { + return err + } + + profileName := c.String("profile") + if profileName != "" { + p, err := profiles.Profile(profileName) + if err != nil { + return err + } + url, err := cfcfg.GetCommonFateURL(p) + if err != nil { + return err + } + if url == nil { + return errors.New("the profile exists but it is not configured with with a Common Fate console url") + } + consoleURL = url.String() + } else { + foundStartURLs := map[string]bool{} + for _, profile := range profiles.ProfileNames { + p, err := profiles.Profile(profile) + if err != nil { + return err + } + url, err := cfcfg.GetCommonFateURL(p) + if err != nil { + clio.Debug(err) + } + if url != nil { + foundStartURLs[url.String()] = true + } + } + keys := make([]string, 0, len(foundStartURLs)) + for k := range foundStartURLs { + keys = append(keys, k) + } + if len(keys) == 0 { + // fall back to the config file + cfFileConfig, err := sdkconfig.LoadDefault(ctx) + if err != nil { + clio.Debug(fmt.Errorf("could not load profile from config file: %w", err)) + return errors.New("no Common Fate deployment urls found in your aws config or the default config file, you can setup now with 'granted login'") + } + consoleURL = cfFileConfig.APIURL + } + if len(keys) == 1 { + consoleURL = keys[0] + } + + err = survey.AskOne(&survey.Select{ + Message: "Please select which Common Fate deployment you would like to open: ", + Options: keys, + }, &consoleURL) + if err != nil { + return err + } + + } + + clio.Infof("Opening the Common Fate console (%s) in your default browser...", consoleURL) + + // uses the default browser to open the console + return browser.OpenURL(consoleURL) + }, +} diff --git a/pkg/granted/credential_process.go b/pkg/granted/credential_process.go index cf4f9ec9..35c02eca 100644 --- a/pkg/granted/credential_process.go +++ b/pkg/granted/credential_process.go @@ -1,17 +1,27 @@ package granted import ( + "context" "encoding/json" "fmt" "time" + "connectrpc.com/connect" "github.com/aws/aws-sdk-go-v2/aws" "github.com/pkg/errors" "github.com/common-fate/clio" + "github.com/common-fate/grab" + "github.com/common-fate/sdk/eid" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + "github.com/common-fate/sdk/service/access/grants" + identitysvc "github.com/common-fate/sdk/service/identity" + "github.com/fwdcloudsec/granted/pkg/accessrequest" "github.com/fwdcloudsec/granted/pkg/cfaws" + "github.com/fwdcloudsec/granted/pkg/cfcfg" "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/securestorage" + sethRetry "github.com/sethvargo/go-retry" "github.com/urfave/cli/v2" ) @@ -92,7 +102,72 @@ var CredentialProcess = cli.Command{ credentials, err := profile.AssumeTerminal(c.Context, cfaws.ConfigOpts{Duration: duration, UsingCredentialProcess: true, CredentialProcessAutoLogin: autoLogin, UseAuthorizationCode: cfg.UseAuthorizationCode}) if err != nil { - return err + // We first check if there was an active grant for this profile, and if there was, allow 30s of retries before bailing out + cfg, cfConfigErr := cfcfg.Load(c.Context, profile) + if cfConfigErr != nil { + clio.Debugw("failed to load cfconfig, skipping check for active grants in a common fate deployment", "error", cfConfigErr) + return err + } + + grantsClient := grants.NewFromConfig(cfg) + idClient := identitysvc.NewFromConfig(cfg) + callerID, callerIDErr := idClient.GetCallerIdentity(c.Context, connect.NewRequest(&accessv1alpha1.GetCallerIdentityRequest{})) + if callerIDErr != nil { + clio.Debugw("failed to load caller identity for user", "error", callerIDErr) + // return the original error + return err + } + grants, queryGrantsErr := grab.AllPages(c.Context, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Grant, *string, error) { + grants, err := grantsClient.QueryGrants(c.Context, connect.NewRequest(&accessv1alpha1.QueryGrantsRequest{ + Principal: callerID.Msg.Principal.Eid, + Target: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), + // This API needs to be updated to use specifiers, for now, fetch all active grants and check for a match on the role name + // Role: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), + Status: accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE.Enum(), + })) + if err != nil { + return nil, nil, err + } + return grants.Msg.Grants, &grants.Msg.NextPageToken, nil + }) + + if queryGrantsErr != nil { + clio.Debugw("failed to query for active grants", "error", queryGrantsErr) + // return the original error + return err + } + + var foundActiveGrant bool + for _, grant := range grants { + if grant.Role.Name == profile.AWSConfig.SSORoleName { + clio.Debugw("found active grant matching the profile, will retry assuming role", "grant", grant) + foundActiveGrant = true + break + } + } + if !foundActiveGrant { + clio.Debug("did not find any matching active grants for the profile, will not retry assuming role") + clio.Debugw("could not assume role due to the following error, notifying user to try requesting access", "error", err) + err := accessrequest.Profile{Name: profileName}.Save() + if err != nil { + return err + } + return errors.New("You don't have access but you can request it with 'granted request latest'") + } + + // there is an active grant so retry assuming because the error may be transient + b := sethRetry.NewFibonacci(time.Second) + b = sethRetry.WithMaxDuration(time.Second*30, b) + err = sethRetry.Do(c.Context, b, func(ctx context.Context) (err error) { + credentials, err = profile.AssumeTerminal(c.Context, cfaws.ConfigOpts{Duration: duration, UsingCredentialProcess: true, CredentialProcessAutoLogin: autoLogin}) + if err != nil { + return sethRetry.RetryableError(err) + } + return nil + }) + if err != nil { + return err + } } if !cfg.DisableCredentialProcessCache { clio.Debugw("storing refreshed credentials in credential process cache", "expires", credentials.Expires.String(), "canExpire", credentials.CanExpire, "timeNow", time.Now().String()) diff --git a/pkg/granted/eks/config.go b/pkg/granted/eks/config.go new file mode 100644 index 00000000..467363c7 --- /dev/null +++ b/pkg/granted/eks/config.go @@ -0,0 +1,93 @@ +package eks + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/granted/proxy" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + "github.com/fatih/color" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" +) + +func OpenKubeConfig() (*api.Config, string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, "", err + } + + kubeConfigPath := filepath.Join(homeDir, ".kube", "config") + + loader := clientcmd.ClientConfigLoadingRules{ + Precedence: []string{kubeConfigPath}, + WarnIfAllMissing: true, + Warner: func(err error) { + // debug log the warning if teh file does not exist + // it will default to creating a new file + clio.Debug(err) + }, + } + config, err := loader.Load() + if err != nil { + return nil, "", err + } + + return config, kubeConfigPath, nil +} + +func AddContextToConfig(ensureAccessOutput *proxy.EnsureAccessOutput[*accessv1alpha1.AWSEKSProxyOutput], port int) error { + + kc, kubeConfigPath, err := OpenKubeConfig() + if err != nil { + return err + } + + clusterContextName := fmt.Sprintf("cf-grant-to-%s-as-%s", ensureAccessOutput.GrantOutput.EksCluster.Name, ensureAccessOutput.GrantOutput.ServiceAccountName) + // Use the same name for the context and the cluster, so that each grant is assigned a unique entry for the cluster + clusterName := clusterContextName + + username := ensureAccessOutput.GrantOutput.ServiceAccountName + + // remove an existing value for the context being added/updated + delete(kc.Contexts, clusterContextName) + // remove existing cluster definitions so they can be reset + delete(kc.Clusters, clusterName) + // remove existing user definitions so they can be reset + delete(kc.AuthInfos, username) + + newCluster := api.NewCluster() + newCluster.Server = fmt.Sprintf("http://localhost:%d", port) + newCluster.InsecureSkipTLSVerify = true + //add the new cluster and context back in + kc.Clusters[clusterName] = newCluster + + newContext := api.NewContext() + newContext.Cluster = clusterName + newContext.AuthInfo = username + // @TODO, teams may wish to specify a default namespace for each user or cluster? + newContext.Namespace = "default" + kc.Contexts[clusterContextName] = newContext + + newUser := api.NewAuthInfo() + newUser.Impersonate = username + kc.AuthInfos[username] = newUser + + err = clientcmd.WriteToFile(*kc, kubeConfigPath) + if err != nil { + return err + } + + //set the context + clio.Infof("EKS proxy is ready for connections") + clio.Infof("Your `~/.kube/config` file has been updated with a new cluster context. To connect to this cluster, run the following command to switch your current context:") + clio.Log(color.YellowString("kubectl config use-context %s", clusterContextName)) + clio.NewLine() + clio.Infof("Or using the --context flag with kubectl: %s", color.YellowString("kubectl --context=%s", clusterContextName)) + clio.NewLine() + + return nil + +} diff --git a/pkg/granted/eks/eks.go b/pkg/granted/eks/eks.go new file mode 100644 index 00000000..79b3237f --- /dev/null +++ b/pkg/granted/eks/eks.go @@ -0,0 +1,169 @@ +package eks + +import ( + "context" + "errors" + + "connectrpc.com/connect" + + "github.com/common-fate/clio" + "github.com/common-fate/grab" + "github.com/fwdcloudsec/granted/pkg/cfcfg" + "github.com/fwdcloudsec/granted/pkg/granted/proxy" + "github.com/common-fate/sdk/config" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + "github.com/common-fate/sdk/service/access" + + "github.com/urfave/cli/v2" +) + +var Command = cli.Command{ + Name: "eks", + Usage: "Granted EKS plugin", + Description: "Granted EKS plugin", + Subcommands: []*cli.Command{&proxyCommand}, +} + +// isLocalMode is used where some behaviour needs to be changed to run against a local development proxy server +func isLocalMode(c *cli.Context) bool { + return c.String("mode") == "local" +} + +var proxyCommand = cli.Command{ + Name: "proxy", + Usage: "The Proxy plugin is used in conjunction with a Commnon Fate deployment to request temporary access to an AWS EKS Cluster", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "target", Aliases: []string{"cluster"}}, + &cli.StringFlag{Name: "role", Aliases: []string{"service-account"}}, + &cli.StringFlag{Name: "reason", Usage: "Provide a reason for requesting access to the role"}, + &cli.StringSliceFlag{Name: "attach", Usage: "Attach justifications to your request, such as a Jira ticket id or url `--attach=TP-123`"}, + &cli.BoolFlag{Name: "confirm", Aliases: []string{"y"}, Usage: "Skip confirmation prompts for access requests"}, + &cli.BoolFlag{Name: "wait", Value: true, Usage: "Wait for the access request to be approved."}, + &cli.BoolFlag{Name: "no-cache", Usage: "Disables caching of session credentials and forces a refresh", EnvVars: []string{"GRANTED_NO_CACHE"}}, + &cli.DurationFlag{Name: "duration", Aliases: []string{"d"}, Usage: "The duration for your access request"}, + &cli.StringFlag{Name: "mode", Hidden: true, Usage: "What mode to run the proxy command in, [remote,local], local is used in development to connect to a local instance of the proxy server rather than remote via SSM", Value: "remote"}, + }, + Action: func(c *cli.Context) error { + ctx := c.Context + cfg, err := config.LoadDefault(ctx) + if err != nil { + return err + } + + err = cfg.Initialize(ctx, config.InitializeOpts{}) + if err != nil { + return err + } + + ensuredAccess, err := proxy.EnsureAccess(ctx, cfg, proxy.EnsureAccessInput[*accessv1alpha1.AWSEKSProxyOutput]{ + Target: c.String("target"), + Role: c.String("role"), + Duration: c.Duration("duration"), + Reason: c.String("reason"), + Attachments: c.StringSlice("attach"), + Confirm: c.Bool("confirm"), + Wait: c.Bool("wait"), + PromptForEntitlement: promptForClusterAndRole, + GetGrantOutput: func(msg *accessv1alpha1.GetGrantOutputResponse) (*accessv1alpha1.AWSEKSProxyOutput, error) { + output := msg.GetOutputAwsEksProxy() + if output == nil { + return nil, errors.New("unexpected grant output, this indicates an error in the Common Fate Provisioning process, you should contect your Common Fate administrator") + } + return output, nil + }, + }) + if err != nil { + return err + } + + requestURL, err := cfcfg.GenerateRequestURL(cfg.APIURL, ensuredAccess.Grant.AccessRequestId) + if err != nil { + return err + } + + serverPort, localPort, err := proxy.Ports(isLocalMode(c)) + if err != nil { + return err + } + + clio.Debugw("prepared ports for access", "serverPort", serverPort, "localPort", localPort) + // In local mode ssm is not used, instead, the command connects directly to the proxy service running in local dev + // Return early because there is nothing to startup + if !isLocalMode(c) { + err = proxy.WaitForSSMConnectionToProxyServer(ctx, proxy.WaitForSSMConnectionToProxyServerOpts{ + AWSConfig: proxy.AWSConfig{ + SSOAccountID: ensuredAccess.GrantOutput.EksCluster.AccountId, + SSORoleName: ensuredAccess.GrantOutput.SsoRoleName, + SSORegion: ensuredAccess.GrantOutput.SsoRegion, + SSOStartURL: ensuredAccess.GrantOutput.SsoStartUrl, + Region: ensuredAccess.GrantOutput.EksCluster.Region, + SSMSessionTarget: ensuredAccess.GrantOutput.SsmSessionTarget, + NoCache: c.Bool("no-cache"), + }, + DisplayOpts: proxy.DisplayOpts{ + Command: "aws eks proxy", + SessionType: "EKS Proxy", + }, + ConnectionOpts: proxy.ConnectionOpts{ + ServerPort: serverPort, + LocalPort: localPort, + }, + GrantID: ensuredAccess.Grant.Id, + RequestID: ensuredAccess.Grant.AccessRequestId, + }) + if err != nil { + return err + } + } + + // Rather than the user having to specify a port via a flag, the proxy command just grabs an unused port to use. + // it means that each time you run the + tempPort, err := proxy.GrabUnusedPort() + if err != nil { + return err + } + + underlyingProxyServerConn, yamuxStreamConnection, err := proxy.InitiateSessionConnection(cfg, proxy.InitiateSessionConnectionInput{ + GrantID: ensuredAccess.Grant.Id, + RequestURL: requestURL, + LocalPort: localPort, + }) + if err != nil { + return err + } + defer func() { _ = underlyingProxyServerConn.Close() }() + defer func() { _ = yamuxStreamConnection.Close() }() + + err = AddContextToConfig(ensuredAccess, tempPort) + if err != nil { + return err + } + + return proxy.ListenAndProxy(ctx, yamuxStreamConnection, tempPort, requestURL) + }, +} + +// promptForClusterAndRole lists all available eks cluster entitlements for the user and displays a table selector UI +func promptForClusterAndRole(ctx context.Context, cfg *config.Context) (*accessv1alpha1.Entitlement, error) { + accessClient := access.NewFromConfig(cfg) + entitlements, err := grab.AllPages(ctx, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Entitlement, *string, error) { + res, err := accessClient.QueryEntitlements(ctx, connect.NewRequest(&accessv1alpha1.QueryEntitlementsRequest{ + PageToken: grab.Value(nextToken), + TargetType: grab.Ptr("AWS::EKS::Cluster"), + })) + if err != nil { + return nil, nil, err + } + return res.Msg.Entitlements, &res.Msg.NextPageToken, nil + }) + if err != nil { + return nil, err + } + + // check here to avoid nil pointer errors later + if len(entitlements) == 0 { + return nil, errors.New("you don't have access to any EKS Clusters") + } + + return proxy.PromptEntitlements(entitlements, "Cluster", "Service Account", "Select a cluster to connect to: ") +} diff --git a/pkg/granted/entrypoint.go b/pkg/granted/entrypoint.go index 7679f9f2..4cf289b8 100644 --- a/pkg/granted/entrypoint.go +++ b/pkg/granted/entrypoint.go @@ -8,13 +8,22 @@ import ( "github.com/common-fate/clio" "github.com/common-fate/clio/cliolog" + "github.com/common-fate/glide-cli/cmd/command" + "github.com/common-fate/useragent" "github.com/fwdcloudsec/granted/internal/build" "github.com/fwdcloudsec/granted/pkg/chromemsg" "github.com/fwdcloudsec/granted/pkg/config" + "github.com/fwdcloudsec/granted/pkg/granted/auth" "github.com/fwdcloudsec/granted/pkg/granted/doctor" + "github.com/fwdcloudsec/granted/pkg/granted/eks" + "github.com/fwdcloudsec/granted/pkg/granted/exp" "github.com/fwdcloudsec/granted/pkg/granted/middleware" + "github.com/fwdcloudsec/granted/pkg/granted/rds" "github.com/fwdcloudsec/granted/pkg/granted/registry" + "github.com/fwdcloudsec/granted/pkg/granted/request" "github.com/fwdcloudsec/granted/pkg/granted/settings" + "github.com/fwdcloudsec/granted/pkg/securestorage" + "github.com/pkg/errors" "github.com/urfave/cli/v2" "go.uber.org/zap" ) @@ -48,8 +57,15 @@ func GetCliApp() *cli.App { middleware.WithBeforeFuncs(&CredentialProcess, middleware.WithAutosync()), ®istry.ProfileRegistryCommand, &ConsoleCommand, + &login, + &exp.Command, &CacheCommand, + &auth.Command, + &request.Command, &doctor.Command, + &rds.Command, + &CFCommand, + &eks.Command, }, // Granted may be invoked via our browser extension, which uses the Native Messaging // protocol to communicate with the Granted CLI. If invoked this way, the browser calls @@ -90,6 +106,8 @@ func GetCliApp() *cli.App { if err := config.SetupConfigFolder(); err != nil { return err } + // set the user agent + c.Context = useragent.NewContext(c.Context, "granted", build.Version) err = chromemsg.ConfigureHost() if err != nil { @@ -102,3 +120,25 @@ func GetCliApp() *cli.App { return app } + +var login = cli.Command{ + Name: "login", + Usage: "Log in to Glide [deprecated]", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "lazy", Usage: "When the lazy flag is used, a login flow will only be started when the access token is expired"}, + }, + Action: func(c *cli.Context) error { + clio.Warn("this command is deprecated and will be removed in a future release") + clio.Warn("use granted auth login if you are trying to authenticate with a Common Fate deployment") + + k, err := securestorage.NewCF().Storage.Keyring() + if err != nil { + return errors.Wrap(err, "loading keyring") + } + + // wrap the nested CLI command with the keyring + lf := command.LoginFlow{Keyring: k} + + return lf.LoginAction(c) + }, +} diff --git a/pkg/granted/exp/exp.go b/pkg/granted/exp/exp.go new file mode 100644 index 00000000..ed0efba6 --- /dev/null +++ b/pkg/granted/exp/exp.go @@ -0,0 +1,16 @@ +// Package exp holds experimental commands. +// The API and arguments of these these commands are subject to change. +package exp + +import ( + "github.com/fwdcloudsec/granted/pkg/granted/exp/request" + "github.com/urfave/cli/v2" +) + +var Command = cli.Command{ + Name: "experimental", + Aliases: []string{"exp"}, + Subcommands: []*cli.Command{ + &request.Command, + }, +} diff --git a/pkg/granted/exp/request/request.go b/pkg/granted/exp/request/request.go new file mode 100644 index 00000000..71aee1ee --- /dev/null +++ b/pkg/granted/exp/request/request.go @@ -0,0 +1,758 @@ +package request + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/briandowns/spinner" + "github.com/common-fate/awsconfigfile" + "github.com/common-fate/clio" + "github.com/common-fate/clio/clierr" + "github.com/common-fate/common-fate/pkg/types" + "github.com/common-fate/glide-cli/pkg/client" + cfconfig "github.com/common-fate/glide-cli/pkg/config" + "github.com/common-fate/glide-cli/pkg/profilesource" + "github.com/fwdcloudsec/granted/pkg/accessrequest" + "github.com/fwdcloudsec/granted/pkg/cache" + "github.com/fwdcloudsec/granted/pkg/cfaws" + grantedConfig "github.com/fwdcloudsec/granted/pkg/config" + "github.com/fwdcloudsec/granted/pkg/frecency" + "github.com/fwdcloudsec/granted/pkg/securestorage" + "github.com/hako/durafmt" + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/pkg/errors" + "github.com/urfave/cli/v2" + "golang.org/x/sync/errgroup" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "gopkg.in/ini.v1" +) + +var Command = cli.Command{ + Name: "request", + Usage: "Request access to a role", + Subcommands: []*cli.Command{ + &awsCommand, + &latestCommand, + }, +} + +var awsCommand = cli.Command{ + Name: "aws", + Usage: "Request access to an AWS role", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "account", Usage: "The AWS account ID"}, + &cli.StringFlag{Name: "role", Usage: "The AWS role"}, + &cli.StringFlag{Name: "reason", Usage: "A reason for access"}, + &cli.DurationFlag{Name: "duration", Usage: "Duration of request, defaults to max duration of the access rule."}, + }, + Action: func(c *cli.Context) error { + return requestAccess(c.Context, requestAccessOpts{ + account: c.String("account"), + role: c.String("role"), + reason: c.String("reason"), + duratiuon: c.Duration("duration"), + }) + }, +} + +var latestCommand = cli.Command{ + Name: "latest", + Usage: "Request access to the latest AWS role you attempted to use", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "reason", Usage: "A reason for access"}, + &cli.DurationFlag{Name: "duration", Usage: "Duration of request, defaults to max duration of the access rule."}, + }, + Action: func(c *cli.Context) error { + role, err := accessrequest.LatestRole() + if err != nil { + return err + } + + clio.Infof("requesting access to account %s with role %s", role.Account, role.Role) + + return requestAccess(c.Context, requestAccessOpts{ + account: role.Account, + role: role.Role, + reason: c.String("reason"), + duratiuon: c.Duration("duration"), + }) + }, +} + +type requestAccessOpts struct { + account string + role string + reason string + duratiuon time.Duration +} + +func requestAccess(ctx context.Context, opts requestAccessOpts) error { + + cfcfg, err := cfconfig.Load() + if err != nil { + return err + } + + k, err := securestorage.NewCF().Storage.Keyring() + if err != nil { + return errors.Wrap(err, "loading keyring") + } + + // creates the Common Fate API client + cf, err := client.FromConfig(ctx, cfcfg, client.WithKeyring(k), client.WithLoginHint("granted login")) + if err != nil { + return err + } + + depID := cfcfg.CurrentOrEmpty().DashboardURL + + accounts, existingRules, accessRulesForAccount, err := RefreshCachedAccessRules(ctx, depID, cf) + if err != nil { + return err + } + + gConf, err := grantedConfig.Load() + if err != nil { + return errors.Wrap(err, "unable to load granted config") + } + + if gConf.CommonFateDefaultSSORegion == "" || gConf.CommonFateDefaultSSOStartURL == "" { + clio.Info("We need to do some once-off set up so that we can automatically populate your AWS config file (~/.aws/config) with the latest profiles after an Access Request is approved") + } + + if gConf.CommonFateDefaultSSORegion == "" { + p := &survey.Input{ + Message: "Your AWS SSO region:", + Help: "The AWS region that your IAM Identity Center instance is hosted in.", + } + err = survey.AskOne(p, &gConf.CommonFateDefaultSSORegion) + if err != nil { + return err + } + err = gConf.Save() + if err != nil { + return err + } + } + + if gConf.CommonFateDefaultSSOStartURL == "" { + p := &survey.Input{ + Message: "Your AWS SSO Start URL:", + Help: "The sign in URL for AWS SSO (e.g. 'https://example.awsapps.com/start')", + } + err = survey.AskOne(p, &gConf.CommonFateDefaultSSOStartURL) + if err != nil { + return err + } + err = gConf.Save() + if err != nil { + return err + } + } + + // a mapping of the selected survey prompt option, back to the actual value + // e.g. "my-account-name (123456789012)" -> 123456789012 + selectedAccountMap := map[string]string{} + var accountOptions []string + for _, a := range accounts { + option := fmt.Sprintf("%s (%s)", a.Label, a.Value) + accountOptions = append(accountOptions, option) + selectedAccountMap[option] = a.Value + } + + var selectedAccountOption string + selectedAccountID := opts.account + + if selectedAccountID == "" { + clio.Debugw("prompting for accounts", "accounts", accounts) + + prompt := &survey.Select{ + Message: "Account", + Options: accountOptions, + } + err = survey.AskOne(prompt, &selectedAccountOption) + if err != nil { + return err + } + + selectedAccountID = selectedAccountMap[selectedAccountOption] + } + + selectedAccountInfo, ok := accounts[selectedAccountID] + if !ok { + clio.Info("account not found in cache, refreshing cache...") + + err = clearCachedAccessRules(depID) + if err != nil { + return err + } + + accounts, _, accessRulesForAccount, err = RefreshCachedAccessRules(ctx, depID, cf) + if err != nil { + return err + } + selectedAccountID := opts.account + + selectedAccountInfo, ok = accounts[selectedAccountID] + + if !ok { + return clierr.New(fmt.Sprintf("account %s not found", selectedAccountID), clierr.Info("run 'granted exp request aws' to see a list of available accounts")) + } + + } + + ruleIDs := accessRulesForAccount[selectedAccountID] + + // note: we use a map here to de-duplicate accounts. + // this means that the RuleID in the accounts map is not necessarily + // the *only* Access Rule which grants access to that account. + permissionSets := map[string]cache.AccessTarget{} + + for _, rule := range existingRules { + if _, ok := ruleIDs[rule.ID]; !ok { + continue + } + + for _, t := range rule.Targets { + if t.Type != "permissionSetArn" { + continue + } + + permissionSets[t.Value] = t + } + } + + // map of permission set option label to Access Rule ID + // AdminAccess -> {"rul_123": true} + permissionSetRuleIDs := map[string]map[string]bool{} + + // map of permission set option label to permission set value + permissionSetValues := map[string]string{} + + var permissionSetOptions []string + for _, a := range permissionSets { + permissionSetOptions = append(permissionSetOptions, a.Label) // label only for permission sets (the ARN is difficult to interpret and the labels are unique) + + if _, ok := permissionSetRuleIDs[a.Label]; !ok { + permissionSetRuleIDs[a.Label] = map[string]bool{} + } + + permissionSetRuleIDs[a.Label][a.RuleID] = true + permissionSetValues[a.Label] = a.Value + } + + selectedRole := opts.role + + if selectedRole == "" { + prompt := &survey.Select{ + Message: "Role", + Options: permissionSetOptions, + } + err = survey.AskOne(prompt, &selectedRole) + if err != nil { + return err + } + } + + permissionSetArn, ok := permissionSetValues[selectedRole] + if !ok { + return clierr.New(fmt.Sprintf("role %s not found", selectedAccountID), clierr.Infof("run 'granted exp request aws --account %s' to see a list of available roles", selectedAccountID)) + } + + selectedPermissionSetRuleIDs := permissionSetRuleIDs[selectedRole] + + // find Access Rules that match the permission set and the account + // we need to find the intersection between permissionSetRuleIDs and accessRulesForAccount + // matchingAccessRule tracks the current Access Rule which we'll use to request access against. + var matchingAccessRule *cache.AccessRule + + for ruleID := range ruleIDs { + if _, ok := selectedPermissionSetRuleIDs[ruleID]; ok { + + // the Access Rule matches both the account and the permission set and could be selected + rule := existingRules[ruleID] + + clio.Debugw("considering access rule", "rule.proposed", rule, "rule.matched", matchingAccessRule) + + // if we haven't found a match yet, set the matching access rule as this one. + if matchingAccessRule == nil { + matchingAccessRule = &rule + continue + } + + // if we've found a match, use this rule if it's lesser "resistance" than the existing + // matched one. + + // the proposed rule will take priority if it doesn't require approval + if matchingAccessRule.RequiresApproval && !rule.RequiresApproval { + matchingAccessRule = &rule + continue + } + + // the proposed rule will take priority if it has a longer duration + if matchingAccessRule.RequiresApproval == rule.RequiresApproval && + matchingAccessRule.DurationSeconds < rule.DurationSeconds { + matchingAccessRule = &rule + continue + } + } + } + + clio.Debugw("matched access rule", "rule.matched", matchingAccessRule) + + reason := opts.reason + + fr, err := frecency.Load("reasons") + if err != nil { + return err + } + + if reason == "" { + var suggestions []string + for _, entry := range fr.Entries { + e := entry.Entry.(string) + suggestions = append(suggestions, e) + } + + reasonPrompt := &survey.Input{ + Message: "Reason for access:", + Help: "Will be stored in audit trails and associated with you", + Suggest: func(toComplete string) []string { + var matched []string + for _, s := range suggestions { + if fuzzy.Match(toComplete, s) { + matched = append(matched, s) + } + } + + return matched + }, + } + err = survey.AskOne(reasonPrompt, &reason) + if err != nil { + return err + } + } + + err = fr.Upsert(reason) + if err != nil { + clio.Errorw("error updating frecency log", "error", err) + } + + // only print the one-liner if --reason wasn't provided + if opts.reason == "" { + clio.NewLine() + clio.Infof("Run this one-liner command to request access in future:\ngranted exp request aws --account %s --role %s --reason \"%s\"", selectedAccountID, selectedRole, reason) + clio.NewLine() + } + + si := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + si.Suffix = " requesting access..." + si.Writer = os.Stderr + si.Start() + + // the current version of the API requires `With` fields to be provided + // *only* if the Access Rule has multiple options for that field. + var with []types.CreateRequestWith + request := types.CreateRequestWith{ + AdditionalProperties: make(map[string][]string), + } + + var accountIdCount, permissionSetCount int + + for _, t := range matchingAccessRule.Targets { + if t.Type == "accountId" { + accountIdCount++ + } + if t.Type == "permissionSetArn" { + permissionSetCount++ + } + } + + // check if the 'accountId' field needs to be included + if accountIdCount > 1 { + request.AdditionalProperties["accountId"] = []string{selectedAccountID} + } + + // check if the 'permissionSetArn' field needs to be included + if permissionSetCount > 1 { + request.AdditionalProperties["permissionSetArn"] = []string{permissionSetArn} + } + + // withPtr is set to null if the `With` field doesn't contain anything. + // it is used to avoid API bad request errors. + var withPtr *[]types.CreateRequestWith + if len(request.AdditionalProperties) > 0 { + with = append(with, request) + withPtr = &with + } + + requestDuration := matchingAccessRule.DurationSeconds + if opts.duratiuon != 0 && int(opts.duratiuon.Seconds()) < requestDuration { + requestDuration = int(opts.duratiuon.Seconds()) + } else if int(opts.duratiuon.Seconds()) > requestDuration { + clio.Warn("The maximum time set for this access request is ", durafmt.Parse(time.Duration(requestDuration)*time.Second).LimitFirstN(1).String()) + } + + _, err = cf.UserCreateRequestWithResponse(ctx, types.UserCreateRequestJSONRequestBody{ + AccessRuleId: matchingAccessRule.ID, + Reason: &reason, + Timing: types.RequestTiming{ + DurationSeconds: requestDuration, + }, + With: withPtr, + }) + + if err != nil { + if strings.Contains(err.Error(), "this request overlaps an existing grant") { + clio.Warn("This request has already been approved, continuing anyway...") + } else { + return err + } + } + + si.Stop() + + // Call granted sso populate here + + startURL := gConf.CommonFateDefaultSSOStartURL + + region := gConf.CommonFateDefaultSSORegion + + configFilename := cfaws.GetAWSConfigPath() + + config, err := ini.LoadSources(ini.LoadOptions{ + AllowNonUniqueSections: false, + SkipUnrecognizableLines: false, + AllowNestedValues: true, + }, configFilename) + if err != nil { + if !os.IsNotExist(err) { + return err + } + config = ini.Empty() + } + + pruneStartURLs := []string{startURL} + + g := awsconfigfile.Generator{ + Config: config, + ProfileNameTemplate: awsconfigfile.DefaultProfileNameTemplate, + NoCredentialProcess: false, + Prefix: "", + PruneStartURLs: pruneStartURLs, + } + + ps := profilesource.Source{SSORegion: region, StartURL: startURL, Client: cf, DashboardURL: cfcfg.CurrentOrEmpty().DashboardURL} + + g.AddSource(ps) + clio.Info("Updating your AWS config file (~/.aws/config) with profiles from Common Fate...") + err = g.Generate(ctx) + if err != nil { + return err + } + + err = config.SaveTo(configFilename) + if err != nil { + return err + } + + // find the latest Access Request + res, err := cf.UserListRequestsWithResponse(ctx, &types.UserListRequestsParams{}) + if err != nil { + return err + } + + latestRequest := res.JSON200.Requests[0] + + reqURL, err := url.Parse(cfcfg.CurrentOrEmpty().DashboardURL) + if err != nil { + return err + } + reqURL.Path = path.Join("/requests", latestRequest.ID) + + // Access Request: Approved (https://commonfate.example.com/requests/req_12345) + clio.Infof("Access Request: %s (%s)", cases.Title(language.English).String(strings.ToLower(string(latestRequest.Status))), reqURL) + + fullName := fmt.Sprintf("%s/%s", selectedAccountInfo.Label, selectedRole) + fullName = strings.ReplaceAll(fullName, " ", "-") // Replacing spaces with "-" to make export AWS_PROFILE work properly + + if latestRequest.Status == types.RequestStatusAPPROVED { + durationDescription := durafmt.Parse(time.Duration(requestDuration) * time.Second).LimitFirstN(1).String() + profile, err := cfaws.LoadProfileByAccountIdAndRole(selectedAccountID, selectedRole) + if err != nil { + // make sure to print err.Error(), rather than just err. + // If the argument to Errorw is an error rather than a string, zap will print the stack trace from where the error originated. + // This makes the log output look quite messy. + clio.Errorw("error while trying to automatically detect if profile is active", "error", err.Error()) + clio.Infof("To use the profile with the AWS CLI, sync your ~/.aws/config by running 'granted sso populate'. Then, run:\nexport AWS_PROFILE=%s", fullName) + return nil + } + + if profile == nil { + clio.Errorw("unable to automatically await access because profile was not found") + clio.Infof("To use the profile with the AWS CLI, sync your ~/.aws/config by running 'granted sso populate'. Then, run:\nexport AWS_PROFILE=%s", fullName) + return nil + } + ssoAssumer := cfaws.AwsSsoAssumer{} + profile.ProfileType = ssoAssumer.Type() + + clio.Debugf("attempting to assume the profile: %s to see that it is ready for use.", profile.Name) + si := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + si.Suffix = " waiting for the profile to be ready..." + si.Writer = os.Stderr + si.Start() + + // run assume with retry such that even if assume fails due to latency issue in provisioning, user will not have to rerun the command. + _, err = profile.AssumeTerminal(ctx, cfaws.ConfigOpts{ + ShouldRetryAssuming: aws.Bool(true), + }) + if err != nil { + // make sure to print err.Error(), rather than just err. + // If the argument to Errorw is an error rather than a string, zap will print the stack trace from where the error originated. + // This makes the log output look quite messy. + clio.Errorw("error while trying to automatically detect if profile is active by assuming the role", "error", err.Error()) + clio.Infof("To use the profile with the AWS CLI, sync your ~/.aws/config by running 'granted sso populate'. Then, run:\nexport AWS_PROFILE=%s", fullName) + return nil + } + si.Stop() + + clio.Successf("[%s] Access is activated (expires in %s)", fullName, durationDescription) + clio.NewLine() + clio.Infof("To use the profile with the AWS CLI, run:\nexport AWS_PROFILE=%s", fullName) + return nil + } + clio.NewLine() + clio.Infof("Your request is not yet approved, to use the profile with the AWS CLI once it is approved, sync your ~/.aws/config by running 'granted sso populate'. Then, run:\nexport AWS_PROFILE=%s", fullName) + + return nil +} + +func RefreshCachedAccessRules(ctx context.Context, depID string, cf *types.ClientWithResponses) (accounts map[string]cache.AccessTarget, existingRules map[string]cache.AccessRule, accessRulesForAccount map[string]map[string]bool, err error) { + //try refreshing the cache and repulling accounts + // note: we use a map here to de-duplicate accounts. + // this means that the RuleID in the accounts map is not necessarily + // the *only* Access Rule which grants access to that account. + accounts = map[string]cache.AccessTarget{} + + existingRules, err = getCachedAccessRules(depID) + if err != nil { + return nil, nil, nil, err + } + + rules, err := cf.UserListAccessRulesWithResponse(ctx) + if err != nil { + return nil, nil, nil, err + + } + + for _, r := range rules.JSON200.AccessRules { + var g errgroup.Group + + g.Go(func() error { + return updateCachedAccessRule(ctx, updateCacheOpts{ + Rule: r, + Existing: existingRules, + DeploymentID: depID, + CF: cf, + }) + }) + + err = g.Wait() + if err != nil { + return nil, nil, nil, err + } + + } + + // refresh the cache + newexistingRules, err := getCachedAccessRules(depID) + if err != nil { + return nil, nil, nil, err + } + accessRulesForAccount = map[string]map[string]bool{} + + for _, rule := range newexistingRules { + for _, t := range rule.Targets { + if t.Type == "accountId" { + if _, ok := accessRulesForAccount[t.Value]; !ok { + accessRulesForAccount[t.Value] = map[string]bool{} + } + accounts[t.Value] = t + accessRulesForAccount[t.Value][rule.ID] = true + } + } + } + + return accounts, existingRules, accessRulesForAccount, nil +} + +func getCachedAccessRules(depID string) (map[string]cache.AccessRule, error) { + cacheFolder, err := getCacheFolder(depID) + if err != nil { + return nil, err + } + + files, err := os.ReadDir(cacheFolder) + if err != nil { + return nil, errors.Wrap(err, "reading cache folder") + } + + // map of rule ID to the rule itself + rules := map[string]cache.AccessRule{} + + for _, f := range files { + // the name of the file is the rule ID (e.g. `rul_123`) + ruleBytes, err := os.ReadFile(path.Join(cacheFolder, f.Name())) + if err != nil { + return nil, err + } + var rule cache.AccessRule + err = json.Unmarshal(ruleBytes, &rule) + if err != nil { + return nil, err + } + + rules[f.Name()] = rule + } + + return rules, nil +} + +func clearCachedAccessRules(depID string) error { + cacheFolder, err := getCacheFolder(depID) + if err != nil { + return err + } + + return os.RemoveAll(cacheFolder) +} + +type updateCacheOpts struct { + Rule types.AccessRule + Existing map[string]cache.AccessRule + DeploymentID string + CF *client.Client +} + +func updateCachedAccessRule(ctx context.Context, opts updateCacheOpts) error { + r := opts.Rule + if opts.Rule.Target.Provider.Type != "aws-sso" { + clio.Debugw("skipping syncing rule: only aws-sso provider type supported", "rule.provider.type", opts.Rule.Target.Provider.Type) + return nil + } + + existing, ok := opts.Existing[r.ID] + + if ok { + // the rule exists in the cache - check whether it's been updated + // since we last saw it. + cacheUpdatedAt := time.Unix(existing.UpdatedAt, 0) + if !opts.Rule.UpdatedAt.After(opts.Rule.UpdatedAt) { + clio.Debugw("rule is up to date: skipping sync", "rule.id", r.ID, "cache.updated_at", cacheUpdatedAt.Unix(), "rule.updated_at", opts.Rule.UpdatedAt.Unix()) + return nil + } + clio.Debugw("rule is out of date", "rule.id", r.ID, "cache.updated_at", cacheUpdatedAt.Unix(), "rule.updated_at", opts.Rule.UpdatedAt.Unix()) + } + + // otherwise, update the cache + row := cache.AccessRule{ + ID: r.ID, + Name: r.Name, + DeploymentID: opts.DeploymentID, + TargetProviderID: r.Target.Provider.Id, + TargetProviderType: r.Target.Provider.Type, + CreatedAt: r.CreatedAt.Unix(), + UpdatedAt: r.UpdatedAt.Unix(), + DurationSeconds: r.TimeConstraints.MaxDurationSeconds, + } + + // our API doesn't easily expose whether manual approval is required + // on an Access Rule, so we need to fetch approvers separately. + approvers, err := opts.CF.UserGetAccessRuleApproversWithResponse(ctx, r.ID) + if err != nil { + return err + } + + if len(approvers.JSON200.Users) > 0 { + row.RequiresApproval = true + } + + clio.Debugw("updated requires approval", "rule.id", r.ID, "requires_approval", row.RequiresApproval) + + details, err := opts.CF.UserGetAccessRuleWithResponse(ctx, r.ID) + if err != nil { + return err + } + + for k, v := range details.JSON200.Target.Arguments.AdditionalProperties { + for _, o := range v.Options { + t := cache.AccessTarget{ + RuleID: r.ID, + Type: k, + Label: o.Label, + Value: o.Value, + } + + if o.Description != nil { + t.Description = *o.Description + } + row.Targets = append(row.Targets, t) + } + } + + clio.Debugw("updated access targets", "rule.id", r.ID, "targets.count", len(row.Targets)) + + cacheFolder, err := getCacheFolder(opts.DeploymentID) + if err != nil { + return err + } + + filename := filepath.Join(cacheFolder, r.ID) + + ruleBytes, err := json.Marshal(row) + if err != nil { + return err + } + + err = os.WriteFile(filename, ruleBytes, 0644) + if err != nil { + return err + } + + return nil +} + +func getCacheFolder(depID string) (string, error) { + configFolder, err := grantedConfig.GrantedCacheFolder() + if err != nil { + return "", err + } + depURL, err := url.Parse(depID) + if err != nil { + return "", err + } + + // ~/.granted/common-fate-cache/commonfate.example.com/access-rules + cacheFolder := path.Join(configFolder, "common-fate-cache", depURL.Hostname(), "access-rules") + + if _, err := os.Stat(cacheFolder); os.IsNotExist(err) { + clio.Debugw("cache folder does not exist, creating", "folder", cacheFolder, "error", err) + err = os.MkdirAll(cacheFolder, 0755) + if err != nil { + return "", errors.Wrapf(err, "creating cache folder %s", cacheFolder) + } + } + + return cacheFolder, nil +} diff --git a/pkg/granted/proxy/ensureaccess.go b/pkg/granted/proxy/ensureaccess.go new file mode 100644 index 00000000..646f3568 --- /dev/null +++ b/pkg/granted/proxy/ensureaccess.go @@ -0,0 +1,127 @@ +package proxy + +import ( + "context" + "errors" + "time" + + "connectrpc.com/connect" + "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/hook/accessrequesthook" + "github.com/common-fate/sdk/config" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + "github.com/common-fate/sdk/service/access/grants" + sethRetry "github.com/sethvargo/go-retry" + "google.golang.org/protobuf/types/known/durationpb" +) + +func durationOrDefault(duration time.Duration) *durationpb.Duration { + var out *durationpb.Duration + if duration != 0 { + out = durationpb.New(duration) + } + return out +} + +type EnsureAccessInput[T any] struct { + Target string + Role string + Duration time.Duration + Reason string + Attachments []string + Confirm bool + Wait bool + PromptForEntitlement func(ctx context.Context, cfg *config.Context) (*accessv1alpha1.Entitlement, error) + GetGrantOutput func(msg *accessv1alpha1.GetGrantOutputResponse) (T, error) +} +type EnsureAccessOutput[T any] struct { + GrantOutput T + Grant *accessv1alpha1.Grant +} + +// ensureAccess checks for an existing grant or creates a new one if it does not exist +func EnsureAccess[T any](ctx context.Context, cfg *config.Context, input EnsureAccessInput[T]) (*EnsureAccessOutput[T], error) { + + accessRequestInput := accessrequesthook.NoEntitlementAccessInput{ + Target: input.Target, + Role: input.Role, + Reason: input.Reason, + Attachments: input.Attachments, + Duration: durationOrDefault(input.Duration), + Confirm: input.Confirm, + Wait: input.Wait, + StartTime: time.Now(), + } + + if accessRequestInput.Target == "" && accessRequestInput.Role == "" { + selectedEntitlement, err := input.PromptForEntitlement(ctx, cfg) + if err != nil { + return nil, err + } + clio.Debugw("selected target and role manually", "selectedEntitlement", selectedEntitlement) + accessRequestInput.Target = selectedEntitlement.Target.Eid.Display() + accessRequestInput.Role = selectedEntitlement.Role.Eid.Display() + } + + hook := accessrequesthook.Hook{} + retry, result, _, err := hook.NoEntitlementAccess(ctx, cfg, accessRequestInput) + if err != nil { + return nil, err + } + + retryDuration := time.Minute * 1 + if input.Wait { + //if wait is specified, increase the timeout to 15 minutes. + retryDuration = time.Minute * 15 + } + + if retry { + // reset the start time for the timer (otherwise it shows 2s, 7s, 12s etc) + accessRequestInput.StartTime = time.Now() + + b := sethRetry.NewConstant(5 * time.Second) + b = sethRetry.WithMaxDuration(retryDuration, b) + err = sethRetry.Do(ctx, b, func(ctx context.Context) (err error) { + + //also proactively check if request has been approved and attempt to activate + result, err = hook.RetryNoEntitlementAccess(ctx, cfg, accessRequestInput) + if err != nil { + + return sethRetry.RetryableError(err) + } + + return nil + }) + if err != nil { + return nil, err + } + + } + + if result == nil || len(result.Grants) == 0 { + return nil, errors.New("could not load grant from Common Fate") + } + + grant := result.Grants[0] + + grantsClient := grants.NewFromConfig(cfg) + + grantOutput, err := grantsClient.GetGrantOutput(ctx, connect.NewRequest(&accessv1alpha1.GetGrantOutputRequest{ + Id: grant.Grant.Id, + })) + if err != nil { + return nil, err + } + + clio.Debugw("found grant output", "output", grantOutput) + + grantOutputFromRes, err := input.GetGrantOutput(grantOutput.Msg) + if err != nil { + return nil, err + } + + return &EnsureAccessOutput[T]{ + GrantOutput: grantOutputFromRes, + Grant: grant.Grant, + }, nil +} diff --git a/pkg/granted/proxy/initiateconnection.go b/pkg/granted/proxy/initiateconnection.go new file mode 100644 index 00000000..946d1a99 --- /dev/null +++ b/pkg/granted/proxy/initiateconnection.go @@ -0,0 +1,55 @@ +package proxy + +import ( + "fmt" + "net" + + "github.com/common-fate/clio" + "github.com/common-fate/clio/clierr" + "github.com/common-fate/sdk/config" + "github.com/common-fate/sdk/handshake" + "github.com/hashicorp/yamux" +) + +type InitiateSessionConnectionInput struct { + GrantID string + RequestURL string + LocalPort int +} + +// InitiateSessionConnection starts a new tcp connection to through the SSM port forward and completes a handshake with the proxy server +// the result is a yamux session which is used to multiplex client connections +func InitiateSessionConnection(cfg *config.Context, input InitiateSessionConnectionInput) (net.Conn, *yamux.Session, error) { + + // First dial the local SSM portforward, which will be running on a randomly chosen port + // or the local proxy server instance if it's local dev mode + // this establishes the initial connection to the Proxy server + clio.Debugw("dialing proxy server", "host", fmt.Sprintf("localhost:%d", input.LocalPort)) + rawServerConn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", input.LocalPort)) + if err != nil { + return nil, nil, clierr.New("failed to establish a connection to the remote proxy server", clierr.Error(err), clierr.Infof("Your grant may have expired, you can check the status here: %s and retry connecting", input.RequestURL)) + } + // Next, a handshake is performed between the cli client and the Proxy server + // this handshake establishes the users identity to the Proxy, and also the validity of a Database grant + handshaker := handshake.NewHandshakeClient(rawServerConn, input.GrantID, cfg.TokenSource) + handshakeResult, err := handshaker.Handshake() + if err != nil { + return nil, nil, clierr.New("failed to authenticate connection to the remote proxy server", clierr.Error(err), clierr.Infof("Your grant may have expired, you can check the status here: %s and retry connecting", input.RequestURL)) + } + clio.Debugw("handshakeResult", "result", handshakeResult) + + // When the handshake process has completed successfully, we use yamux to establish a multiplexed stream over the existing connection + // We use a multiplexed stream here so that multiple clients can be connected and have their logs attributed to the same session in our audit trail + // To the clients, this is completely opaque + multiplexedServerClient, err := yamux.Client(rawServerConn, nil) + if err != nil { + return nil, nil, err + } + + // Sanity check to confirm that the multiplexed stream is working + _, err = multiplexedServerClient.Ping() + if err != nil { + return nil, nil, fmt.Errorf("failed to healthcheck the network connection to the proxy server: %w", err) + } + return rawServerConn, multiplexedServerClient, nil +} diff --git a/pkg/granted/proxy/listenandproxy.go b/pkg/granted/proxy/listenandproxy.go new file mode 100644 index 00000000..454440fc --- /dev/null +++ b/pkg/granted/proxy/listenandproxy.go @@ -0,0 +1,99 @@ +package proxy + +import ( + "context" + "fmt" + "io" + "net" + + "github.com/common-fate/clio" + "github.com/common-fate/clio/clierr" + "github.com/hashicorp/yamux" + "go.uber.org/zap" +) + +// ListenAndProxy will listen for new client connections and start a stream over the established proxy server session. +// if the proxy server terminates the session, like when a grant expires, this listener will detect it and terminate the CLI commmand with an error explaining what happened +func ListenAndProxy(ctx context.Context, yamuxStreamConnection *yamux.Session, clientConnectionPort int, requestURL string) error { + ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", clientConnectionPort)) + if err != nil { + return fmt.Errorf("failed to start listening for connections on port: %d. %w", clientConnectionPort, err) + } + defer func() { _ = ln.Close() }() + + type result struct { + conn net.Conn + err error + } + resultChan := make(chan result, 100) + go func() { + for { + select { + case <-ctx.Done(): + return + default: + conn, err := ln.Accept() + result := result{ + err: err, + } + if err == nil { + result.conn = conn + } + resultChan <- result + } + } + }() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-yamuxStreamConnection.CloseChan(): + return clierr.New("The connection to the proxy server has ended", clierr.Infof("Your grant may have expired, you can check the status here: %s and retry connecting", requestURL)) + case result := <-resultChan: + if result.err != nil { + return fmt.Errorf("failed to accept connection: %w", err) + } + if yamuxStreamConnection.IsClosed() { + return clierr.New("failed to accept connection for client because the proxy server connection has ended", clierr.Infof("Your grant may have expired, you can check the status here: %s and retry connecting", requestURL)) + } + go func(clientConn net.Conn) { + + // A stream is opened for this connection, streams are used just like a net.Conn and can read and write data + // A stream can only be opened while the grant is still valid, and each new connection will validate the parameters + sessionConn, err := yamuxStreamConnection.OpenStream() + if err != nil { + clio.Error("Failed to establish a new connection to the remote via the proxy server.") + clio.Error(err) + clio.Infof("Your grant may have expired, you can check the status here: %s", requestURL) + return + } + + clio.Infof("Connection accepted for session [%v]", sessionConn.StreamID()) + + // If a stream successfully connects, that means that a connection to the target is now open + // at this point the connection traffic is handed off and the connection is effectively directly from the client and the target + // with queries being intercepted and logged to the audit trail in Common Fate + // if the grant becomes incative at any time the connection is terminated immediately + go func() { + defer func() { _ = clientConn.Close() }() + defer func() { _ = sessionConn.Close() }() + _, err := io.Copy(sessionConn, clientConn) + if err != nil { + clio.Debugw("error writing data from client to server usually this is just because the proxy session ended.", "streamId", sessionConn.StreamID(), zap.Error(err)) + } + clio.Infof("Connection ended for session [%v]", sessionConn.StreamID()) + }() + go func() { + defer func() { _ = clientConn.Close() }() + defer func() { _ = sessionConn.Close() }() + _, err := io.Copy(clientConn, sessionConn) + if err != nil { + clio.Debugw("error writing data from server to client usually this is just because the proxy session ended.", "streamId", sessionConn.StreamID(), zap.Error(err)) + } + + }() + }(result.conn) + } + } +} diff --git a/pkg/granted/proxy/ports.go b/pkg/granted/proxy/ports.go new file mode 100644 index 00000000..fd6c0534 --- /dev/null +++ b/pkg/granted/proxy/ports.go @@ -0,0 +1,37 @@ +package proxy + +import ( + "net" +) + +// Returns the proxy port to connect to and a local port to send client connections to +// in production, an SSM portforward process is running which is used to connect to the proxy server +// and over the top of this connection, a handshake process takes place and connection multiplexing is used to handle multiple database clients +func Ports(isLocalMode bool) (serverPort, localPort int, err error) { + // in local mode the SSM port forward is not used can skip using ssm and just use a local port forward instead + if isLocalMode { + return 7070, 7070, nil + } + // find an unused local port to use for the ssm server + // the user doesn't directly connect to this, they connect through our local proxy + // which adds authentication + ssmPortforwardLocalPort, err := GrabUnusedPort() + if err != nil { + return 0, 0, err + } + return 8080, ssmPortforwardLocalPort, nil +} + +func GrabUnusedPort() (int, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return 0, err + } + + port := listener.Addr().(*net.TCPAddr).Port + err = listener.Close() + if err != nil { + return 0, err + } + return port, nil +} diff --git a/pkg/granted/proxy/prompt.go b/pkg/granted/proxy/prompt.go new file mode 100644 index 00000000..9a4f6493 --- /dev/null +++ b/pkg/granted/proxy/prompt.go @@ -0,0 +1,83 @@ +package proxy + +import ( + "fmt" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/charmbracelet/lipgloss" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + "github.com/mattn/go-runewidth" +) + +func filterMultiToken(filterValue string, optValue string, optIndex int) bool { + optValue = strings.ToLower(optValue) + filters := strings.Split(strings.ToLower(filterValue), " ") + for _, filter := range filters { + if !strings.Contains(optValue, filter) { + return false + } + } + return true +} +func PromptEntitlements(entitlements []*accessv1alpha1.Entitlement, targetHeader string, roleHeader string, promptMessage string) (*accessv1alpha1.Entitlement, error) { + type Column struct { + Title string + Width int + } + cols := []Column{{Title: targetHeader, Width: 40}, {Title: roleHeader, Width: 40}} + var s = make([]string, 0, len(cols)) + for _, col := range cols { + style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) + renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) + s = append(s, lipgloss.NewStyle().Bold(true).Padding(0).Render(renderedCell)) + } + header := lipgloss.NewStyle().PaddingLeft(2).Render(lipgloss.JoinHorizontal(lipgloss.Left, s...)) + var options []string + optionsMap := make(map[string]*accessv1alpha1.Entitlement) + for i, entitlement := range entitlements { + style := lipgloss.NewStyle().Width(cols[0].Width).MaxWidth(cols[0].Width).Inline(true) + target := lipgloss.NewStyle().Bold(true).Padding(0).Render(style.Render(runewidth.Truncate(entitlement.Target.Display(), cols[0].Width, "…"))) + + style = lipgloss.NewStyle().Width(cols[1].Width).MaxWidth(cols[1].Width).Inline(true) + role := lipgloss.NewStyle().Bold(true).Padding(0).Render(style.Render(runewidth.Truncate(entitlement.Role.Display(), cols[1].Width, "…"))) + + option := lipgloss.JoinHorizontal(lipgloss.Left, target, role) + options = append(options, option) + optionsMap[option] = entitlements[i] + } + + originalSelectTemplate := survey.SelectQuestionTemplate + survey.SelectQuestionTemplate = fmt.Sprintf(` +{{- define "option"}} + {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}} + {{- .CurrentOpt.Value}}{{ if ne ($.GetDescription .CurrentOpt) "" }} - {{color "cyan"}}{{ $.GetDescription .CurrentOpt }}{{end}} + {{- color "reset"}} +{{end}} +{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} +{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} +{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} +{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}} +{{- else}} + {{- " "}}{{- color "cyan"}}[Use arrows to move, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}} + {{- "\n"}} +%s{{- "\n"}} + {{- range $ix, $option := .PageEntries}} + {{- template "option" $.IterateOption $ix $option}} + {{- end}} +{{- end}}`, header) + + var out string + err := survey.AskOne(&survey.Select{ + Message: promptMessage, + Options: options, + Filter: filterMultiToken, + }, &out) + if err != nil { + return nil, err + } + + survey.SelectQuestionTemplate = originalSelectTemplate + + return optionsMap[out], nil +} diff --git a/pkg/granted/proxy/proxy.go b/pkg/granted/proxy/proxy.go new file mode 100644 index 00000000..40bb8fd6 --- /dev/null +++ b/pkg/granted/proxy/proxy.go @@ -0,0 +1,160 @@ +package proxy + +import ( + "context" + "fmt" + "io" + "os" + "strconv" + "time" + + awsConfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/session-manager-plugin/src/datachannel" + "github.com/aws/session-manager-plugin/src/sessionmanagerplugin/session" + "github.com/aws/session-manager-plugin/src/sessionmanagerplugin/session/portsession" + "github.com/briandowns/spinner" + "github.com/common-fate/clio" + "github.com/common-fate/clio/clierr" + "github.com/common-fate/grab" + "github.com/fwdcloudsec/granted/internal/build" + "github.com/fwdcloudsec/granted/pkg/cfaws" + + "github.com/common-fate/xid" +) + +type DisplayOpts struct { + //the e.g `aws rds proxy` which is used to fill in a help prompt + Command string + // like `EKS Proxy` or `RDS proxy` + SessionType string +} +type AWSConfig struct { + SSOAccountID string + SSORoleName string + SSORegion string + SSOStartURL string + Region string + SSMSessionTarget string + NoCache bool +} +type ConnectionOpts struct { + ServerPort int + LocalPort int +} +type WaitForSSMConnectionToProxyServerOpts struct { + AWSConfig AWSConfig + DisplayOpts DisplayOpts + ConnectionOpts ConnectionOpts + GrantID string + RequestID string +} + +// WaitForSSMConnectionToProxyServer starts a session with SSM and waits for the connection to be ready +func WaitForSSMConnectionToProxyServer(ctx context.Context, opts WaitForSSMConnectionToProxyServerOpts) error { + + p := &cfaws.Profile{ + Name: opts.GrantID, + ProfileType: "AWS_SSO", + AWSConfig: awsConfig.SharedConfig{ + SSOAccountID: opts.AWSConfig.SSOAccountID, + SSORoleName: opts.AWSConfig.SSORoleName, + SSORegion: opts.AWSConfig.SSORegion, + SSOStartURL: opts.AWSConfig.SSOStartURL, + }, + Initialised: true, + } + + creds, err := p.AssumeTerminal(ctx, cfaws.ConfigOpts{ + ShouldRetryAssuming: grab.Ptr(true), + DisableCache: opts.AWSConfig.NoCache, + }) + if err != nil { + return err + } + + ssmReadyForConnectionsChan := make(chan struct{}) + + awscfg, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken))) + if err != nil { + return err + } + awscfg.Region = opts.AWSConfig.Region + ssmClient := ssm.NewFromConfig(awscfg) + + var sessionOutput *ssm.StartSessionOutput + + documentName := "AWS-StartPortForwardingSession" + startSessionInput := ssm.StartSessionInput{ + Target: &opts.AWSConfig.SSMSessionTarget, + DocumentName: &documentName, + Parameters: map[string][]string{ + "portNumber": {strconv.Itoa(opts.ConnectionOpts.ServerPort)}, + "localPortNumber": {strconv.Itoa(opts.ConnectionOpts.LocalPort)}, + }, + Reason: grab.Ptr(fmt.Sprintf("Session started for Granted %s connection with Common Fate. GrantID: %s, AccessRequestID: %s", opts.DisplayOpts.SessionType, opts.GrantID, opts.RequestID)), + } + + sessionOutput, err = ssmClient.StartSession(ctx, &startSessionInput) + if err != nil { + return clierr.New("Failed to start AWS SSM port forward session", + clierr.Error(err), + clierr.Infof("You can try re-running this command with the verbose flag to see detailed logs, '%s --verbose %s'", build.GrantedBinaryName(), opts.DisplayOpts.Command), + clierr.Infof("In rare cases, where the proxy service has been re-deployed while your grant was active, you will need to close your request in Common Fate and request access again 'cf access close request --id=%s' This is usually indicated by an error message containing '(TargetNotConnected) when calling the StartSession'", opts.RequestID)) + } + + clientId := xid.New("gtd") + ssmSession := session.Session{ + StreamUrl: *sessionOutput.StreamUrl, + SessionId: *sessionOutput.SessionId, + TokenValue: *sessionOutput.TokenValue, + IsAwsCliUpgradeNeeded: false, + Endpoint: fmt.Sprintf("localhost:%d", opts.ConnectionOpts.LocalPort), + DataChannel: &datachannel.DataChannel{}, + ClientId: clientId, + } + + startingProxySpinner := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + startingProxySpinner.Suffix = fmt.Sprintf(" Starting %s...", opts.DisplayOpts.SessionType) + startingProxySpinner.Writer = os.Stderr + startingProxySpinner.Start() + defer startingProxySpinner.Stop() + + // registers the PortSession feature within the ssm library + _ = portsession.PortSession{} + + // the SSMDebugLogger serves two purposes here + // 1. writes ssm session logs to clio.Debug which can be viewed using the --verbose flag + // 2. scans the output for the string "Waiting for connections..." which indicates that the SSM connection was successful + // The notifier will notify the ssmReadyForConnectionsChan which means we can connect to the proxy to complete the initial handshake + ssmLogger := &SSMDebugLogger{ + Writers: []io.Writer{ + &NotifyOnSubstringMatchWriter{ + Phrase: "Waiting for connections...", + Callback: func() { ssmReadyForConnectionsChan <- struct{}{} }, + }, + DebugWriter{}, + }, + } + + // Connect to the Proxy server using SSM + go func() { + // Execute starts the ssm connection + err = ssmSession.Execute(ssmLogger) + if err != nil { + clio.Error("AWS SSM port forward session closed with an error") + clio.Error(err) + clio.Info("You can try re-running this command with the verbose flag to see detailed logs, '%s --verbose %s'", build.GrantedBinaryName(), opts.DisplayOpts.Command) + clio.Infof("In rare cases, where the proxy service has been re-deployed while your grant was active, you will need to close your request in Common Fate and request access again 'cf access close request --id=%s' This is usually indicated by an error message containing '(TargetNotConnected) when calling the StartSession'", opts.RequestID) + } + }() + + // waits for the ssm session to start or context to be cancelled + select { + case <-ssmReadyForConnectionsChan: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/pkg/granted/proxy/ssm_logger.go b/pkg/granted/proxy/ssm_logger.go new file mode 100644 index 00000000..fb23dcb1 --- /dev/null +++ b/pkg/granted/proxy/ssm_logger.go @@ -0,0 +1,101 @@ +package proxy + +import ( + "fmt" + "io" + + "github.com/aws/session-manager-plugin/src/log" +) + +type SSMDebugLogger struct { + // Writers to write logging output to + Writers []io.Writer +} + +func (l *SSMDebugLogger) WithContext(context ...string) (contextLogger log.T) { + msg := fmt.Sprint(context) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } + return l +} +func (l *SSMDebugLogger) Close() {} +func (l *SSMDebugLogger) Critical(v ...interface{}) error { + msg := fmt.Sprint(v...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } + return nil +} +func (l *SSMDebugLogger) Criticalf(format string, params ...interface{}) error { + msg := fmt.Sprintf(format, params...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } + return nil +} +func (l *SSMDebugLogger) Debug(v ...interface{}) { + msg := fmt.Sprint(v...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } +} +func (l *SSMDebugLogger) Debugf(format string, params ...interface{}) { + msg := fmt.Sprintf(format, params...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } +} +func (l *SSMDebugLogger) Error(v ...interface{}) error { + msg := fmt.Sprint(v...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } + return nil +} +func (l *SSMDebugLogger) Errorf(format string, params ...interface{}) error { + msg := fmt.Sprintf(format, params...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } + return nil +} +func (l *SSMDebugLogger) Flush() {} +func (l *SSMDebugLogger) Info(v ...interface{}) { + msg := fmt.Sprint(v...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } +} +func (l *SSMDebugLogger) Infof(format string, params ...interface{}) { + msg := fmt.Sprintf(format, params...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } +} +func (l *SSMDebugLogger) Trace(v ...interface{}) { + msg := fmt.Sprint(v...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } +} +func (l *SSMDebugLogger) Tracef(format string, params ...interface{}) { + msg := fmt.Sprintf(format, params...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } +} +func (l *SSMDebugLogger) Warn(v ...interface{}) error { + msg := fmt.Sprint(v...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } + return nil +} +func (l *SSMDebugLogger) Warnf(format string, params ...interface{}) error { + msg := fmt.Sprintf(format, params...) + for _, writer := range l.Writers { + _, _ = writer.Write([]byte(msg)) + } + return nil +} diff --git a/pkg/granted/proxy/writers.go b/pkg/granted/proxy/writers.go new file mode 100644 index 00000000..b41e6136 --- /dev/null +++ b/pkg/granted/proxy/writers.go @@ -0,0 +1,30 @@ +package proxy + +import ( + "strings" + + "github.com/common-fate/clio" +) + +// DebugWriter is an io.Writer that writes messages using clio.Debug. +type DebugWriter struct{} + +// Write implements the io.Writer interface for DebugWriter. +func (dw DebugWriter) Write(p []byte) (n int, err error) { + message := string(p) + clio.Debug(message) + return len(p), nil +} + +type NotifyOnSubstringMatchWriter struct { + Phrase string + Callback func() +} + +func (nw *NotifyOnSubstringMatchWriter) Write(p []byte) (n int, err error) { + // Check if the phrase is in the input + if strings.Contains(string(p), nw.Phrase) { + go nw.Callback() + } + return len(p), nil +} diff --git a/pkg/granted/rds/local_port.go b/pkg/granted/rds/local_port.go new file mode 100644 index 00000000..f985d40d --- /dev/null +++ b/pkg/granted/rds/local_port.go @@ -0,0 +1,31 @@ +package rds + +type getLocalPortInput struct { + // OverrideFlag is set by the user using the --port flag + OverrideFlag int + // DefaultFromServer is the port number specified by admins in the Terraform provider + DefaultFromServer int + // Fallback is the port to default to if OverrideFlag and DefaultFromServer are not set + Fallback int +} + +// getLocalPort returns the port number to use for the local port +// +// Common Fate allows admins to set default ports in the Terraform provider and +// users to override them with the --port flag when running granted rds proxy --port +// +// The order of priorities is: +// 1. OverrideFlag +// 2. DefaultFromServer +// 3. Fallback +// +// You should set Fallback to 5432 for PostgreSQL and 3306 for MySQL +func getLocalPort(input getLocalPortInput) int { + if input.OverrideFlag != 0 { + return input.OverrideFlag + } + if input.DefaultFromServer != 0 { + return input.DefaultFromServer + } + return input.Fallback +} diff --git a/pkg/granted/rds/local_port_test.go b/pkg/granted/rds/local_port_test.go new file mode 100644 index 00000000..85136073 --- /dev/null +++ b/pkg/granted/rds/local_port_test.go @@ -0,0 +1,56 @@ +package rds + +import "testing" + +func Test_getLocalPort(t *testing.T) { + type args struct { + input getLocalPortInput + } + tests := []struct { + name string + args args + want int + }{ + // TODO: Add test cases. + { + name: "OverridePortTakesPriority", + args: args{ + input: getLocalPortInput{ + OverrideFlag: 5000, + DefaultFromServer: 8080, + Fallback: 5432, + }, + }, + want: 5000, + }, + { + name: "DefaultFromServerTakesPriority", + args: args{ + input: getLocalPortInput{ + OverrideFlag: 0, + DefaultFromServer: 8080, + Fallback: 5432, + }, + }, + want: 8080, + }, + { + name: "FallbackTakesPriority", + args: args{ + input: getLocalPortInput{ + OverrideFlag: 0, + DefaultFromServer: 0, + Fallback: 5432, + }, + }, + want: 5432, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getLocalPort(tt.args.input); got != tt.want { + t.Errorf("getLocalPort() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/granted/rds/rds.go b/pkg/granted/rds/rds.go new file mode 100644 index 00000000..cbf9df6f --- /dev/null +++ b/pkg/granted/rds/rds.go @@ -0,0 +1,207 @@ +package rds + +import ( + "context" + "errors" + "fmt" + + "connectrpc.com/connect" + + "github.com/common-fate/clio" + "github.com/common-fate/grab" + "github.com/fwdcloudsec/granted/pkg/cfcfg" + "github.com/fwdcloudsec/granted/pkg/granted/proxy" + "github.com/common-fate/sdk/config" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + "github.com/common-fate/sdk/service/access" + "github.com/fatih/color" + + "github.com/urfave/cli/v2" +) + +var Command = cli.Command{ + Name: "rds", + Usage: "Granted RDS plugin", + Description: "Granted RDS plugin", + Subcommands: []*cli.Command{&proxyCommand}, +} + +// isLocalMode is used where some behaviour needs to be changed to run against a local development proxy server +func isLocalMode(c *cli.Context) bool { + return c.String("mode") == "local" +} + +var proxyCommand = cli.Command{ + Name: "proxy", + Usage: "The Proxy plugin is used in conjunction with a Commnon Fate deployment to request temporary access to an AWS RDS Database", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "target", Aliases: []string{"database"}}, + &cli.StringFlag{Name: "role", Aliases: []string{"user"}}, + &cli.IntFlag{Name: "port", Usage: "The local port to forward the database connection to"}, + &cli.StringFlag{Name: "reason", Usage: "Provide a reason for requesting access to the role"}, + &cli.StringSliceFlag{Name: "attach", Usage: "Attach justifications to your request, such as a Jira ticket id or url `--attach=TP-123`"}, + &cli.BoolFlag{Name: "confirm", Aliases: []string{"y"}, Usage: "Skip confirmation prompts for access requests"}, + &cli.BoolFlag{Name: "wait", Value: true, Usage: "Wait for the access request to be approved."}, + &cli.BoolFlag{Name: "no-cache", Usage: "Disables caching of session credentials and forces a refresh", EnvVars: []string{"GRANTED_NO_CACHE"}}, + &cli.DurationFlag{Name: "duration", Aliases: []string{"d"}, Usage: "The duration for your access request"}, + &cli.StringFlag{Name: "mode", Hidden: true, Usage: "What mode to run the proxy command in, [remote,local], local is used in development to connect to a local instance of the proxy server rather than remote via SSM", Value: "remote"}, + }, + Action: func(c *cli.Context) error { + ctx := c.Context + cfg, err := config.LoadDefault(ctx) + if err != nil { + return err + } + + err = cfg.Initialize(ctx, config.InitializeOpts{}) + if err != nil { + return err + } + + ensuredAccess, err := proxy.EnsureAccess(ctx, cfg, proxy.EnsureAccessInput[*accessv1alpha1.AWSRDSOutput]{ + Target: c.String("target"), + Role: c.String("role"), + Duration: c.Duration("duration"), + Reason: c.String("reason"), + Attachments: c.StringSlice("attach"), + Confirm: c.Bool("confirm"), + Wait: c.Bool("wait"), + PromptForEntitlement: promptForDatabaseAndUser, + GetGrantOutput: func(msg *accessv1alpha1.GetGrantOutputResponse) (*accessv1alpha1.AWSRDSOutput, error) { + output := msg.GetOutputAwsRds() + if output == nil { + return nil, errors.New("unexpected grant output, this indicates an error in the Common Fate Provisioning process, you should contect your Common Fate administrator") + } + return output, nil + }, + }) + if err != nil { + return err + } + + requestURL, err := cfcfg.GenerateRequestURL(cfg.APIURL, ensuredAccess.Grant.AccessRequestId) + if err != nil { + return err + } + + serverPort, localPort, err := proxy.Ports(isLocalMode(c)) + if err != nil { + return err + } + + clio.Debugw("prepared ports for access", "serverPort", serverPort, "localPort", localPort) + if !isLocalMode(c) { + err = proxy.WaitForSSMConnectionToProxyServer(ctx, proxy.WaitForSSMConnectionToProxyServerOpts{ + AWSConfig: proxy.AWSConfig{ + SSOAccountID: ensuredAccess.GrantOutput.RdsDatabase.AccountId, + SSORoleName: ensuredAccess.GrantOutput.SsoRoleName, + SSORegion: ensuredAccess.GrantOutput.SsoRegion, + SSOStartURL: ensuredAccess.GrantOutput.SsoStartUrl, + Region: ensuredAccess.GrantOutput.RdsDatabase.Region, + SSMSessionTarget: ensuredAccess.GrantOutput.SsmSessionTarget, + NoCache: c.Bool("no-cache"), + }, + DisplayOpts: proxy.DisplayOpts{ + Command: "aws rds proxy", + SessionType: "RDS Proxy", + }, + ConnectionOpts: proxy.ConnectionOpts{ + ServerPort: serverPort, + LocalPort: localPort, + }, + GrantID: ensuredAccess.Grant.Id, + RequestID: ensuredAccess.Grant.AccessRequestId, + }) + if err != nil { + return err + } + } + + underlyingProxyServerConn, yamuxStreamConnection, err := proxy.InitiateSessionConnection(cfg, proxy.InitiateSessionConnectionInput{ + GrantID: ensuredAccess.Grant.Id, + RequestURL: requestURL, + LocalPort: localPort, + }) + if err != nil { + return err + } + defer func() { _ = underlyingProxyServerConn.Close() }() + defer func() { _ = yamuxStreamConnection.Close() }() + + connectionString, cliString, clientConnectionPort, err := clientConnectionParameters(c, ensuredAccess) + if err != nil { + return err + } + + printConnectionParameters(connectionString, cliString, ensuredAccess.GrantOutput.RdsDatabase.Engine, clientConnectionPort) + + return proxy.ListenAndProxy(ctx, yamuxStreamConnection, clientConnectionPort, requestURL) + }, +} + +// promptForDatabaseAndUser lists all available database entitlements for the user and displays a table selector UI +func promptForDatabaseAndUser(ctx context.Context, cfg *config.Context) (*accessv1alpha1.Entitlement, error) { + accessClient := access.NewFromConfig(cfg) + entitlements, err := grab.AllPages(ctx, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Entitlement, *string, error) { + res, err := accessClient.QueryEntitlements(ctx, connect.NewRequest(&accessv1alpha1.QueryEntitlementsRequest{ + PageToken: grab.Value(nextToken), + TargetType: grab.Ptr("AWS::RDS::Database"), + })) + if err != nil { + return nil, nil, err + } + return res.Msg.Entitlements, &res.Msg.NextPageToken, nil + }) + if err != nil { + return nil, err + } + + // check here to avoid nil pointer errors later + if len(entitlements) == 0 { + return nil, errors.New("you don't have access to any RDS databases") + } + + return proxy.PromptEntitlements(entitlements, "Database", "Role", "Select a database to connect to: ") + +} + +func clientConnectionParameters(c *cli.Context, ensuredAccess *proxy.EnsureAccessOutput[*accessv1alpha1.AWSRDSOutput]) (connectionString, cliString string, port int, err error) { + // Print the connection information to the user based on the database they are connecting to + // the passwords are always 'password' while the username and database will match that of the target being connected to + yellow := color.New(color.FgYellow) + switch ensuredAccess.GrantOutput.RdsDatabase.Engine { + case "postgres", "aurora-postgresql": + port = getLocalPort(getLocalPortInput{ + OverrideFlag: c.Int("port"), + DefaultFromServer: int(ensuredAccess.GrantOutput.DefaultLocalPort), + Fallback: 5432, + }) + + connectionString = yellow.Sprintf("postgresql://%s:password@127.0.0.1:%d/%s?sslmode=disable", ensuredAccess.GrantOutput.User.Username, port, ensuredAccess.GrantOutput.RdsDatabase.Database) + cliString = yellow.Sprintf(`psql "postgresql://%s:password@127.0.0.1:%d/%s?sslmode=disable"`, ensuredAccess.GrantOutput.User.Username, port, ensuredAccess.GrantOutput.RdsDatabase.Database) + case "mysql", "aurora-mysql": + port = getLocalPort(getLocalPortInput{ + OverrideFlag: c.Int("port"), + DefaultFromServer: int(ensuredAccess.GrantOutput.DefaultLocalPort), + Fallback: 3306, + }) + + connectionString = yellow.Sprintf("%s:password@tcp(127.0.0.1:%d)/%s", ensuredAccess.GrantOutput.User.Username, port, ensuredAccess.GrantOutput.RdsDatabase.Database) + cliString = yellow.Sprintf(`mysql -u %s -p'password' -h 127.0.0.1 -P %d %s`, ensuredAccess.GrantOutput.User.Username, port, ensuredAccess.GrantOutput.RdsDatabase.Database) + default: + return "", "", 0, fmt.Errorf("unsupported database engine: %s, maybe you need to update your `cf` cli", ensuredAccess.GrantOutput.RdsDatabase.Engine) + } + return +} + +func printConnectionParameters(connectionString, cliString, engine string, port int) { + clio.NewLine() + clio.Infof("Database proxy ready for connections on 127.0.0.1:%d", port) + clio.NewLine() + + clio.Infof("You can connect now using this connection string: %s", connectionString) + clio.NewLine() + + clio.Infof("Or using the %s cli: %s", engine, cliString) + clio.NewLine() +} diff --git a/pkg/granted/registry/add.go b/pkg/granted/registry/add.go index e1d8778b..b003606c 100644 --- a/pkg/granted/registry/add.go +++ b/pkg/granted/registry/add.go @@ -8,6 +8,7 @@ import ( "github.com/common-fate/clio" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/granted/awsmerge" + "github.com/fwdcloudsec/granted/pkg/granted/registry/cfregistry" "github.com/fwdcloudsec/granted/pkg/granted/registry/gitregistry" "github.com/fwdcloudsec/granted/pkg/testable" @@ -60,12 +61,8 @@ var AddCommand = cli.Command{ priority := c.Int("priority") registryType := c.String("type") - if registryType == "http" { - return fmt.Errorf("HTTP registries are not longer supported in this version of Granted: if you are impacted by this please raise an issue: https://github.com/fwdcloudsec/granted/issues/new") - } - - if registryType != "git" { - return fmt.Errorf("invalid registry type provided: %s. must be 'git'", c.String("type")) + if registryType != "git" && registryType != "http" { + return fmt.Errorf("invalid registry type provided: %s. must be 'git' or 'http'", c.String("type")) } for _, r := range gConf.ProfileRegistry.Registries { @@ -88,82 +85,160 @@ var AddCommand = cli.Command{ Type: registryType, } - registry, err := gitregistry.New(gitregistry.Opts{ - Name: name, - URL: URL, - Path: pathFlag, - Filename: configFileName, - Ref: ref, - RequiredKeys: requiredKey, - Interactive: true, - }) - - if err != nil { - return err - } - src, err := registry.AWSProfiles(ctx, true) - if err != nil { - return err - } + if registryType == "git" { + registry, err := gitregistry.New(gitregistry.Opts{ + Name: name, + URL: URL, + Path: pathFlag, + Filename: configFileName, + Ref: ref, + RequiredKeys: requiredKey, + Interactive: true, + }) - dst, filepath, err := loadAWSConfigFile() - if err != nil { - return err - } + if err != nil { + return err + } + src, err := registry.AWSProfiles(ctx, true) + if err != nil { + return err + } - merged, err := awsmerge.WithRegistry(src, dst, awsmerge.RegistryOpts{ - Name: name, - PrefixAllProfiles: prefixAllProfiles, - PrefixDuplicateProfiles: prefixDuplicateProfiles, - }) - var dpe awsmerge.DuplicateProfileError - if errors.As(err, &dpe) { - clio.Warnf(err.Error()) + dst, filepath, err := loadAWSConfigFile() + if err != nil { + return err + } - const ( - DUPLICATE = "Add registry name as prefix to all duplicate profiles for this registry" - ABORT = "Abort, I will manually fix this" - ) + merged, err := awsmerge.WithRegistry(src, dst, awsmerge.RegistryOpts{ + Name: name, + PrefixAllProfiles: prefixAllProfiles, + PrefixDuplicateProfiles: prefixDuplicateProfiles, + }) + var dpe awsmerge.DuplicateProfileError + if errors.As(err, &dpe) { + clio.Warnf(err.Error()) + + const ( + DUPLICATE = "Add registry name as prefix to all duplicate profiles for this registry" + ABORT = "Abort, I will manually fix this" + ) + + options := []string{DUPLICATE, ABORT} + + in := survey.Select{Message: "Please select which option would you like to choose to resolve: ", Options: options} + var selected string + err = testable.AskOne(&in, &selected) + if err != nil { + return err + } + + if selected == ABORT { + return fmt.Errorf("aborting sync for registry %s", name) + } + + registryConfig.PrefixDuplicateProfiles = true + + // try and merge again + merged, err = awsmerge.WithRegistry(src, dst, awsmerge.RegistryOpts{ + Name: name, + PrefixAllProfiles: prefixAllProfiles, + PrefixDuplicateProfiles: true, + }) + if err != nil { + return fmt.Errorf("error after trying to merge profiles again: %w", err) + } + } - options := []string{DUPLICATE, ABORT} + // we have verified that this registry is a valid one and sync is completed. + // so save the new registry to config file. + gConf.ProfileRegistry.Registries = append(gConf.ProfileRegistry.Registries, registryConfig) + err = gConf.Save() + if err != nil { + return err + } - in := survey.Select{Message: "Please select which option would you like to choose to resolve: ", Options: options} - var selected string - err = testable.AskOne(&in, &selected) + err = merged.SaveTo(filepath) if err != nil { return err } - if selected == ABORT { - return fmt.Errorf("aborting sync for registry %s", name) + return nil + } else { + + registry := cfregistry.New(cfregistry.Opts{ + Name: name, + URL: URL, + }) + + if err != nil { + return err + } + src, err := registry.AWSProfiles(ctx, true) + if err != nil { + return err } - registryConfig.PrefixDuplicateProfiles = true + dst, filepath, err := loadAWSConfigFile() + if err != nil { + return err + } - // try and merge again - merged, err = awsmerge.WithRegistry(src, dst, awsmerge.RegistryOpts{ + merged, err := awsmerge.WithRegistry(src, dst, awsmerge.RegistryOpts{ Name: name, PrefixAllProfiles: prefixAllProfiles, - PrefixDuplicateProfiles: true, + PrefixDuplicateProfiles: prefixDuplicateProfiles, }) + var dpe awsmerge.DuplicateProfileError + if errors.As(err, &dpe) { + clio.Warnf(err.Error()) + + const ( + DUPLICATE = "Add registry name as prefix to all duplicate profiles for this registry" + ABORT = "Abort, I will manually fix this" + ) + + options := []string{DUPLICATE, ABORT} + + in := survey.Select{Message: "Please select which option would you like to choose to resolve: ", Options: options} + var selected string + err = testable.AskOne(&in, &selected) + if err != nil { + return err + } + + if selected == ABORT { + return fmt.Errorf("aborting sync for registry %s", name) + } + + registryConfig.PrefixDuplicateProfiles = true + + // try and merge again + merged, err = awsmerge.WithRegistry(src, dst, awsmerge.RegistryOpts{ + Name: name, + PrefixAllProfiles: prefixAllProfiles, + PrefixDuplicateProfiles: true, + }) + if err != nil { + return fmt.Errorf("error after trying to merge profiles again: %w", err) + } + } + + // we have verified that this registry is a valid one and sync is completed. + // so save the new registry to config file. + gConf.ProfileRegistry.Registries = append(gConf.ProfileRegistry.Registries, registryConfig) + err = gConf.Save() if err != nil { - return fmt.Errorf("error after trying to merge profiles again: %w", err) + return err } - } - // we have verified that this registry is a valid one and sync is completed. - // so save the new registry to config file. - gConf.ProfileRegistry.Registries = append(gConf.ProfileRegistry.Registries, registryConfig) - err = gConf.Save() - if err != nil { - return err - } + err = merged.SaveTo(filepath) + if err != nil { + return err + } + + return nil - err = merged.SaveTo(filepath) - if err != nil { - return err } - return nil }, -} \ No newline at end of file +} diff --git a/pkg/granted/registry/cfregistry/cfregistry.go b/pkg/granted/registry/cfregistry/cfregistry.go new file mode 100644 index 00000000..edb209de --- /dev/null +++ b/pkg/granted/registry/cfregistry/cfregistry.go @@ -0,0 +1,156 @@ +package cfregistry + +import ( + "context" + "fmt" + "strings" + "sync" + + "connectrpc.com/connect" + "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/cfcfg" + awsv1alpha1 "github.com/common-fate/sdk/gen/granted/registry/aws/v1alpha1" + "github.com/common-fate/sdk/gen/granted/registry/aws/v1alpha1/awsv1alpha1connect" + "github.com/common-fate/sdk/loginflow" + grantedv1alpha1 "github.com/common-fate/sdk/service/granted/registry" + "gopkg.in/ini.v1" +) + +type Registry struct { + opts Opts + mu sync.Mutex + // client is the profile registry service client. + // + // Do not use client directly. Instead, call + // r.getClient() which will automatically populate it. + client awsv1alpha1connect.ProfileRegistryServiceClient +} + +type Opts struct { + Name string + URL string +} + +// getClient lazily loads the Profile Registry service client. +// +// Becuase the Registry is constructed every time the Granted CLI executes, +// calling `config.LoadDefault()` when creating the registry makes Granted very slow. +// Instead, we only obtain an OIDC token if we actually need to load profiles for the registry. +func (r *Registry) getClient(interactive bool) (awsv1alpha1connect.ProfileRegistryServiceClient, error) { + // if the cached + if r.client != nil { + return r.client, nil + } + + // Load the config from the deployment URL + cfg, err := cfcfg.LoadURL(context.Background(), r.opts.URL) + if err != nil { + // NOTE(josh): ideally we'll bubble up a more strongly typed error in future here, to avoid the string comparison on the error message. + // the OAuth2.0 token is expired so we should prompt the user to log in + if needsToRefreshLogin(err) { + if interactive { + clio.Infof("You need to log into Common Fate to sync your profile registry") + lf := loginflow.NewFromConfig(cfg) + err = lf.Login(context.Background()) + if err != nil { + return nil, err + } + } else { + // in non interactive mode, just return a wrapped error + return nil, fmt.Errorf("you need to log into Common Fate to sync your profile registry using `granted auth login`: %w", err) + } + + } else { + return nil, err + } + } + + accountClient := grantedv1alpha1.NewFromConfig(cfg) + + r.mu.Lock() + defer r.mu.Unlock() + r.client = accountClient + + return r.client, nil +} +func needsToRefreshLogin(err error) bool { + if err == nil { + return false + } + if strings.Contains(err.Error(), "oauth2: token expired") { + return true + } + if strings.Contains(err.Error(), "oauth2: invalid grant") { + return true + } + // Sanity check that error message is matching correctly + if strings.Contains(err.Error(), `oauth2: "token_expired"`) { + return true + } + if strings.Contains(err.Error(), `oauth2: "invalid_grant"`) { + return true + } + + return false +} + +func New(opts Opts) *Registry { + r := Registry{ + opts: opts, + } + + return &r +} + +func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File, error) { + client, err := r.getClient(interactive) + if err != nil { + return nil, err + } + + // call the Profile Registry API to pull the avilable profiles. + done := false + var pageToken string + profiles := []*awsv1alpha1.Profile{} + + for !done { + listProfiles, err := client.ListProfiles(ctx, &connect.Request[awsv1alpha1.ListProfilesRequest]{ + Msg: &awsv1alpha1.ListProfilesRequest{ + PageToken: pageToken, + }, + }) + if err != nil { + return nil, err + } + + profiles = append(profiles, listProfiles.Msg.Profiles...) + + if listProfiles.Msg.NextPageToken == "" { + done = true + } else { + pageToken = listProfiles.Msg.NextPageToken + } + } + + result := ini.Empty() + + for _, profile := range profiles { + + section, err := result.NewSection(profile.Name) + if err != nil { + return nil, err + } + + //expect all the attributes to come from the api with the correct key value pairs + for _, attr := range profile.Attributes { + _, err := section.NewKey(attr.Key, attr.Value) + if err != nil { + return nil, err + } + + } + + } + + return result, nil +} diff --git a/pkg/granted/registry/registry.go b/pkg/granted/registry/registry.go index 7f431925..b3a77677 100644 --- a/pkg/granted/registry/registry.go +++ b/pkg/granted/registry/registry.go @@ -5,6 +5,7 @@ import ( "sort" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" + "github.com/fwdcloudsec/granted/pkg/granted/registry/cfregistry" "github.com/fwdcloudsec/granted/pkg/granted/registry/gitregistry" "gopkg.in/ini.v1" ) @@ -48,7 +49,18 @@ func GetProfileRegistries(interactive bool) ([]loadedRegistry, error) { Config: r, Registry: reg, }) + } else { + //set up a common fate registry + reg := cfregistry.New(cfregistry.Opts{ + Name: r.Name, + URL: r.URL, + }) + registries = append(registries, loadedRegistry{ + Config: r, + Registry: reg, + }) } + } // this will sort the registry based on priority. diff --git a/pkg/granted/request/check.go b/pkg/granted/request/check.go new file mode 100644 index 00000000..d548bd11 --- /dev/null +++ b/pkg/granted/request/check.go @@ -0,0 +1,101 @@ +package request + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/common-fate/clio" + "github.com/common-fate/grab" + "github.com/fwdcloudsec/granted/pkg/cfaws" + "github.com/fwdcloudsec/granted/pkg/cfcfg" + "github.com/fwdcloudsec/granted/pkg/securestorage" + "github.com/common-fate/sdk/eid" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + "github.com/common-fate/sdk/service/access/grants" + identitysvc "github.com/common-fate/sdk/service/identity" + "github.com/urfave/cli/v2" +) + +var checkCommand = cli.Command{ + Name: "check", + Usage: "Check the Common Fate JIT backend to see whether Just-In-Time access to a particular entitlement is active", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "aws-profile", Required: true, Usage: "Check for access for a particular AWS profile"}, + }, + Action: func(c *cli.Context) error { + profiles, err := cfaws.LoadProfiles() + if err != nil { + return err + } + + profileName := c.String("aws-profile") + + profile, err := profiles.LoadInitialisedProfile(c.Context, profileName) + if err != nil { + return err + } + + cfg, err := cfcfg.Load(c.Context, profile) + if err != nil { + return fmt.Errorf("failed to load cfconfig, cannot check for active grants, %w", err) + } + + grantsClient := grants.NewFromConfig(cfg) + idClient := identitysvc.NewFromConfig(cfg) + callerID, err := idClient.GetCallerIdentity(c.Context, connect.NewRequest(&accessv1alpha1.GetCallerIdentityRequest{})) + if err != nil { + return err + } + target := eid.New("AWS::Account", profile.AWSConfig.SSOAccountID) + + grants, err := grab.AllPages(c.Context, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Grant, *string, error) { + grants, err := grantsClient.QueryGrants(c.Context, connect.NewRequest(&accessv1alpha1.QueryGrantsRequest{ + Principal: callerID.Msg.Principal.Eid, + Target: target.ToAPI(), + // This API needs to be updated to use specifiers, for now, fetch all active grants and check for a match on the role name + // Role: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), + Status: accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE.Enum(), + })) + if err != nil { + return nil, nil, err + } + return grants.Msg.Grants, &grants.Msg.NextPageToken, nil + }) + + if err != nil { + clearCacheProfileIfExists(profileName) + return fmt.Errorf("failed to query for active grants: %w", err) + } + + for _, grant := range grants { + if grant.Role.Name == profile.AWSConfig.SSORoleName { + clio.Debugw("found active grant matching the profile, will retry assuming role", "grant", grant) + clio.Successf("access to target %s and role %s is currently active", target, profile.AWSConfig.SSORoleName) + fmt.Println(grant.AccessRequestId) + return nil + } + } + + // no active Access Request exists, so the session token cache should be cleared for the profile. + clearCacheProfileIfExists(profileName) + + return fmt.Errorf("no active Access Request found for target %s and role %s", target, profile.AWSConfig.SSORoleName) + }, +} + +func clearCacheProfileIfExists(profile string) { + cache := securestorage.NewSecureSessionCredentialStorage() + found, err := cache.SecureStorage.HasKey(profile) + if err != nil { + clio.Errorf("error checking cache for profile %q: %s", profile, err) + } + if !found { + return + } + + err = cache.SecureStorage.Clear(profile) + if err != nil { + clio.Errorf("error clearing cache for profile %q: %s", profile, err) + } +} diff --git a/pkg/granted/request/close.go b/pkg/granted/request/close.go new file mode 100644 index 00000000..9f7317bc --- /dev/null +++ b/pkg/granted/request/close.go @@ -0,0 +1,201 @@ +package request + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/AlecAivazis/survey/v2" + "github.com/common-fate/cli/printdiags" + "github.com/common-fate/clio" + "github.com/common-fate/grab" + "github.com/fwdcloudsec/granted/pkg/cfaws" + "github.com/fwdcloudsec/granted/pkg/cfcfg" + "github.com/fwdcloudsec/granted/pkg/testable" + "github.com/common-fate/sdk/config" + "github.com/common-fate/sdk/eid" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + entityv1alpha1 "github.com/common-fate/sdk/gen/commonfate/entity/v1alpha1" + "github.com/common-fate/sdk/service/access/grants" + "github.com/common-fate/sdk/service/access/request" + identitysvc "github.com/common-fate/sdk/service/identity" + "github.com/urfave/cli/v2" +) + +var closeCommand = cli.Command{ + Name: "close", + Usage: "Close an active Just-In-Time access to a particular entitlement", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "profile", Required: false, Usage: "Close a JIT access for a particular AWS profile"}, + &cli.StringFlag{Name: "request-id", Required: false, Usage: "Close a JIT access for a particular access request ID"}, + }, + Action: func(c *cli.Context) error { + + accessRequestID := c.String("request-id") + profileName := c.String("profile") + + if accessRequestID != "" && profileName != "" { + clio.Warn("Both profile and request-id were provided, profile will be ignored") + } + + if accessRequestID != "" { + ctx := c.Context + + cfg, err := config.LoadDefault(ctx) + if err != nil { + return err + } + + client := request.NewFromConfig(cfg) + + closeRes, err := client.CloseAccessRequest(ctx, connect.NewRequest(&accessv1alpha1.CloseAccessRequestRequest{ + Id: accessRequestID, + })) + clio.Debugw("result", "closeAccessRequest", closeRes) + if err != nil { + return fmt.Errorf("failed to close access request: , %w", err) + } + + haserrors := printdiags.Print(closeRes.Msg.Diagnostics, nil) + if !haserrors { + clio.Successf("access request %s is now closed", accessRequestID) + } + + return nil + } + + if profileName != "" { + + profiles, err := cfaws.LoadProfiles() + if err != nil { + return err + } + + profile, err := profiles.LoadInitialisedProfile(c.Context, profileName) + if err != nil { + return err + } + + cfg, err := cfcfg.Load(c.Context, profile) + if err != nil { + return fmt.Errorf("failed to load cfconfig, cannot check for active grants, %w", err) + } + + grantsClient := grants.NewFromConfig(cfg) + idClient := identitysvc.NewFromConfig(cfg) + callerID, err := idClient.GetCallerIdentity(c.Context, connect.NewRequest(&accessv1alpha1.GetCallerIdentityRequest{})) + if err != nil { + return err + } + target := eid.New("AWS::Account", profile.AWSConfig.SSOAccountID) + + grants, err := grab.AllPages(c.Context, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Grant, *string, error) { + grants, err := grantsClient.QueryGrants(c.Context, connect.NewRequest(&accessv1alpha1.QueryGrantsRequest{ + Principal: callerID.Msg.Principal.Eid, + Target: target.ToAPI(), + // This API needs to be updated to use specifiers, for now, fetch all active grants and check for a match on the role name + // Role: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), + Status: accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE.Enum(), + })) + if err != nil { + return nil, nil, err + } + return grants.Msg.Grants, &grants.Msg.NextPageToken, nil + }) + + if err != nil { + clearCacheProfileIfExists(profileName) + return fmt.Errorf("failed to query for active grants: %w", err) + } + + accessClient := request.NewFromConfig(cfg) + + for _, grant := range grants { + if grant.Role.Name == profile.AWSConfig.SSORoleName { + clio.Debugw("found active grant matching the profile, attempting to close grant", "grant", grant) + + res, err := accessClient.CloseAccessRequest(c.Context, connect.NewRequest(&accessv1alpha1.CloseAccessRequestRequest{ + Id: grant.AccessRequestId, + })) + clio.Debugw("result", "res", res) + if err != nil { + return err + } + clio.Successf("access to target %s and role %s is now closed", target, profile.AWSConfig.SSORoleName) + return nil + } + } + + return fmt.Errorf("no active Access Request found for target %s and role %s", target, profile.AWSConfig.SSORoleName) + } + + // Prompt the user with a list of active access requests if no flags are set + ctx := c.Context + cfg, err := config.LoadDefault(ctx) + if err != nil { + return err + } + accessClient := request.NewFromConfig(cfg) + + res, err := accessClient.QueryMyAccessRequests(ctx, connect.NewRequest(&accessv1alpha1.QueryMyAccessRequestsRequest{ + Order: entityv1alpha1.Order_ORDER_DESCENDING.Enum(), + })) + clio.Debugw("result", "res", res) + if err != nil { + return err + } + + userAccessRequests := res.Msg.AccessRequests + if len(res.Msg.AccessRequests) == 0 { + clio.Error("There are no access requests that need to be closed") + return nil + } + + accessRequestsWithNames := []string{} + for _, req := range userAccessRequests { + // For now, add temporary code to check if the access request has granted that need to be closed + // This part will be replaced by the implementation of the GrantStatus filter within QueryAccessRequests + needsDeprovisioning := false + for _, grant := range req.Grants { + + if grant.Status == accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE && grant.ProvisioningStatus != accessv1alpha1.ProvisioningStatus(accessv1alpha1.ProvisioningStatus_PROVISIONING_STATUS_ATTEMPTING) { + needsDeprovisioning = true + break + } + } + if needsDeprovisioning { + accessRequestsWithNames = append(accessRequestsWithNames, req.Id) + } + } + + in := survey.Select{Message: "Please select the access request that you would like to close:", Options: accessRequestsWithNames} + var out string + err = testable.AskOne(&in, &out) + if err != nil { + return err + } + + var selectedAccessRequest string + + for _, r := range userAccessRequests { + if r.Id == out { + selectedAccessRequest = r.Id + } + } + + closeRes, err := accessClient.CloseAccessRequest(ctx, connect.NewRequest(&accessv1alpha1.CloseAccessRequestRequest{ + Id: selectedAccessRequest, + })) + clio.Debugw("result", "closeAccessRequest", closeRes) + if err != nil { + return fmt.Errorf("failed to close access request: , %w", err) + } + + haserrors := printdiags.Print(closeRes.Msg.Diagnostics, nil) + if !haserrors { + clio.Successf("access request %s is now closed", selectedAccessRequest) + } + + return nil + }, +} diff --git a/pkg/granted/request/request.go b/pkg/granted/request/request.go new file mode 100644 index 00000000..41ee861a --- /dev/null +++ b/pkg/granted/request/request.go @@ -0,0 +1,122 @@ +package request + +import ( + "context" + "fmt" + "time" + + "connectrpc.com/connect" + "github.com/common-fate/clio" + "github.com/common-fate/grab" + "github.com/fwdcloudsec/granted/pkg/accessrequest" + "github.com/fwdcloudsec/granted/pkg/cfaws" + "github.com/fwdcloudsec/granted/pkg/cfcfg" + "github.com/fwdcloudsec/granted/pkg/hook/accessrequesthook" + "github.com/common-fate/sdk/eid" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + "github.com/common-fate/sdk/service/access/grants" + identitysvc "github.com/common-fate/sdk/service/identity" + "github.com/hako/durafmt" + "github.com/urfave/cli/v2" + "google.golang.org/protobuf/types/known/durationpb" +) + +var Command = cli.Command{ + Name: "request", + Usage: "Request access to a role", + Subcommands: []*cli.Command{ + &latestCommand, + &checkCommand, + &closeCommand, + }, +} + +var latestCommand = cli.Command{ + Name: "latest", + Usage: "Request access to the latest AWS role you attempted to use", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "reason", Usage: "A reason for access"}, + &cli.StringSliceFlag{Name: "attach", Usage: "Attach justifications to your request, such as a Jira ticket id or url `--attach=TP-123`"}, + &cli.DurationFlag{Name: "duration", Usage: "Duration of request, defaults to max duration of the access rule."}, + &cli.BoolFlag{Name: "confirm", Aliases: []string{"y"}, Usage: "Skip confirmation prompts for access requests"}, + }, + Action: func(c *cli.Context) error { + latest, err := accessrequest.LatestProfile() + if err != nil { + return err + } + + profiles, err := cfaws.LoadProfiles() + if err != nil { + return err + } + + profile, err := profiles.LoadInitialisedProfile(c.Context, latest.Name) + if err != nil { + return err + } + + // We first check if there was an active grant for this profile, and if there was, allow 30s of retries before bailing out + cfg, cfConfigErr := cfcfg.Load(c.Context, profile) + if err != nil { + if cfConfigErr != nil { + clio.Debugw("failed to load cfconfig, skipping check for active grants in a common fate deployment", "error", cfConfigErr) + } + return err + } + + grantsClient := grants.NewFromConfig(cfg) + idClient := identitysvc.NewFromConfig(cfg) + callerID, err := idClient.GetCallerIdentity(c.Context, connect.NewRequest(&accessv1alpha1.GetCallerIdentityRequest{})) + if err != nil { + return fmt.Errorf("failed to load caller identity for user: %w", err) + } + grants, err := grab.AllPages(c.Context, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Grant, *string, error) { + grants, err := grantsClient.QueryGrants(c.Context, connect.NewRequest(&accessv1alpha1.QueryGrantsRequest{ + Principal: callerID.Msg.Principal.Eid, + Target: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), + // This API needs to be updated to use specifiers, for now, fetch all active grants and check for a match on the role name + // Role: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), + Status: accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE.Enum(), + })) + if err != nil { + return nil, nil, err + } + return grants.Msg.Grants, &grants.Msg.NextPageToken, nil + }) + + if err != nil { + return fmt.Errorf("failed to query for active grants: %w", err) + } + + for _, grant := range grants { + if grant.Role.Name == profile.AWSConfig.SSORoleName { + durationDescription := durafmt.Parse(time.Until(grant.ExpiresAt.AsTime())).LimitFirstN(1).String() + clio.Infof("You already have an existing active grant for this profile which expires in %s, you can try assuming it now 'assume %s'", durationDescription, profile.Name) + return nil + } + } + + hook := accessrequesthook.Hook{} + reason := c.String("reason") + duration := c.Duration("duration") + var apiDuration *durationpb.Duration + if duration != 0 { + apiDuration = durationpb.New(duration) + } + + _, _, err = hook.NoAccess(c.Context, accessrequesthook.NoAccessInput{ + Profile: profile, + Reason: reason, + Attachments: c.StringSlice("attach"), + Duration: apiDuration, + Confirm: c.Bool("confirm"), + }) + if err != nil { + return err + } + + return nil + + }, +} diff --git a/pkg/granted/sso.go b/pkg/granted/sso.go index d70ee66e..31c1059f 100644 --- a/pkg/granted/sso.go +++ b/pkg/granted/sso.go @@ -20,6 +20,10 @@ import ( "github.com/common-fate/awsconfigfile" "github.com/common-fate/clio" "github.com/common-fate/clio/clierr" + "github.com/common-fate/glide-cli/cmd/command" + "github.com/common-fate/glide-cli/pkg/client" + cfconfig "github.com/common-fate/glide-cli/pkg/config" + "github.com/common-fate/glide-cli/pkg/profilesource" "github.com/fwdcloudsec/granted/pkg/cfaws" grantedconfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/idclogin" @@ -53,7 +57,7 @@ var GenerateCommand = cli.Command{ &cli.StringFlag{Name: "config", Usage: "Specify the SSO config section in the Granted config file ([SSO.name])", Value: "default"}, &cli.StringFlag{Name: "prefix", Usage: "Specify a prefix for all generated profile names"}, &cli.StringFlag{Name: "sso-region", Usage: "Specify the SSO region"}, - &cli.StringSliceFlag{Name: "source", Usage: "The sources to load AWS profiles from (valid values are: 'aws-sso')", Value: cli.NewStringSlice("aws-sso")}, + &cli.StringSliceFlag{Name: "source", Usage: "The sources to load AWS profiles from (valid values are: 'aws-sso', 'commonfate')", Value: cli.NewStringSlice("aws-sso")}, &cli.BoolFlag{Name: "no-credential-process", Usage: "Generate profiles without the Granted credential-process integration"}, &cli.StringFlag{Name: "profile-template", Usage: "Specify profile name template", Value: awsconfigfile.DefaultProfileNameTemplate}, &cli.StringFlag{Name: "sso-browser-profile", Usage: "Use a pre-existing profile in your browser for SSO login", EnvVars: []string{"GRANTED_SSO_BROWSER_PROFILE"}}, @@ -109,9 +113,13 @@ var GenerateCommand = cli.Command{ case "aws-sso": g.AddSource(AWSSSOSource{SSORegion: ssoRegion, StartURL: startURL, SSOBrowserProfile: ssoBrowserProfile, UseDeviceCode: c.Bool("use-device-code")}) case "commonfate", "common-fate", "cf": - return fmt.Errorf("the common fate profile source is no longer supported: https://www.commonfate.io/blog/winding-down") + ps, err := getCFProfileSource(c, ssoRegion, startURL) + if err != nil { + return err + } + g.AddSource(ps) default: - return fmt.Errorf("unknown profile source %s: allowed sources are aws-sso", s) + return fmt.Errorf("unknown profile source %s: allowed sources are aws-sso, commonfate", s) } } @@ -226,9 +234,13 @@ var PopulateCommand = cli.Command{ case "aws-sso": g.AddSource(AWSSSOSource{SSORegion: ssoRegion, StartURL: startURL, SSOScopes: c.StringSlice("sso-scope"), SSOBrowserProfile: ssoBrowserProfile, UseDeviceCode: c.Bool("use-device-code")}) case "commonfate", "common-fate", "cf": - return fmt.Errorf("the common fate profile source is no longer supported: https://www.commonfate.io/blog/winding-down") + ps, err := getCFProfileSource(c, ssoRegion, startURL) + if err != nil { + return err + } + g.AddSource(ps) default: - return fmt.Errorf("unknown profile source %s: allowed sources are aws-sso", s) + return fmt.Errorf("unknown profile source %s: allowed sources are aws-sso, commonfate", s) } } err = g.Generate(ctx) @@ -337,6 +349,41 @@ var LoginCommand = cli.Command{ }, } +func getCFProfileSource(c *cli.Context, region, startURL string) (profilesource.Source, error) { + kr, err := securestorage.NewCF().Storage.Keyring() + if err != nil { + return profilesource.Source{}, err + } + + // login if the CF API isn't configured + if !cfconfig.IsConfigured() { + lf := command.LoginFlow{Keyring: kr, ForceInteractive: true} + err = lf.LoginAction(c) + if err != nil { + return profilesource.Source{}, err + } + } + + cfg, err := cfconfig.Load() + if err != nil { + return profilesource.Source{}, err + } + + cf, err := client.FromConfig(c.Context, cfg, + client.WithKeyring(kr), + client.WithLoginHint("granted login"), + ) + if err != nil { + return profilesource.Source{}, err + } + + ps := profilesource.Source{SSORegion: region, StartURL: startURL, Client: cf, DashboardURL: cfg.CurrentOrEmpty().DashboardURL} + + clio.Infof("listing available profiles from Common Fate (%s)", ps.DashboardURL) + + return ps, nil +} + type AWSSSOSource struct { SSORegion string StartURL string diff --git a/pkg/hook/accessrequesthook/accessrequesthook.go b/pkg/hook/accessrequesthook/accessrequesthook.go new file mode 100644 index 00000000..4101c8a9 --- /dev/null +++ b/pkg/hook/accessrequesthook/accessrequesthook.go @@ -0,0 +1,549 @@ +package accessrequesthook + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "strings" + "time" + + "connectrpc.com/connect" + "github.com/AlecAivazis/survey/v2" + "github.com/briandowns/spinner" + "github.com/common-fate/cli/printdiags" + "github.com/common-fate/clio" + "github.com/common-fate/grab" + "github.com/fwdcloudsec/granted/pkg/cfaws" + "github.com/fwdcloudsec/granted/pkg/cfcfg" + "github.com/common-fate/sdk/config" + "github.com/common-fate/sdk/eid" + accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" + "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1/accessv1alpha1connect" + "github.com/common-fate/sdk/loginflow" + "github.com/common-fate/sdk/service/access" + "github.com/fatih/color" + "github.com/mattn/go-isatty" + "google.golang.org/protobuf/encoding/protojson" + durationpb "google.golang.org/protobuf/types/known/durationpb" +) + +type Hook struct{} + +type NoAccessInput struct { + Profile *cfaws.Profile + Reason string + Attachments []string + Duration *durationpb.Duration + Confirm bool + Wait bool + StartTime time.Time +} + +func (h Hook) NoAccess(ctx context.Context, input NoAccessInput) (retry bool, justActivated bool, err error) { + + cfg, err := cfcfg.Load(ctx, input.Profile) + if err != nil { + clio.Debugw("failed to load cfconfig, skipping check for active grants in a common fate deployment", "error", err) + return false, false, nil + } + + target := eid.New("AWS::Account", input.Profile.AWSConfig.SSOAccountID) + role := input.Profile.AWSConfig.SSORoleName + + clio.Infof("You don't currently have access to %s, checking if we can request access...\t[target=%s, role=%s, url=%s]", input.Profile.Name, target, role, cfg.AccessURL) + + retry, _, justActivated, err = h.NoEntitlementAccess(ctx, cfg, NoEntitlementAccessInput{ + Target: target.String(), + Role: role, + Reason: input.Reason, + Duration: input.Duration, + Confirm: input.Confirm, + Wait: input.Wait, + StartTime: input.StartTime, + Attachments: input.Attachments, + }) + + return retry, justActivated, err +} + +type NoEntitlementAccessInput struct { + Target string + Role string + Reason string + Attachments []string + Duration *durationpb.Duration + Confirm bool + Wait bool + StartTime time.Time +} + +func (h Hook) NoEntitlementAccess(ctx context.Context, cfg *config.Context, input NoEntitlementAccessInput) (retry bool, result *accessv1alpha1.BatchEnsureResponse, justActivated bool, err error) { + + justActivated = false + + apiURL, err := url.Parse(cfg.APIURL) + if err != nil { + return false, nil, justActivated, err + } + + accessclient := access.NewFromConfig(cfg) + + req := accessv1alpha1.BatchEnsureRequest{ + Entitlements: []*accessv1alpha1.EntitlementInput{ + { + Target: &accessv1alpha1.Specifier{ + Specify: &accessv1alpha1.Specifier_Lookup{ + Lookup: input.Target, + }, + }, + Role: &accessv1alpha1.Specifier{ + Specify: &accessv1alpha1.Specifier_Lookup{ + Lookup: input.Role, + }, + }, + Duration: input.Duration, + }, + }, + Justification: &accessv1alpha1.Justification{}, + } + + hasChanges, result, err := DryRun(ctx, apiURL, accessclient, &req, false, input.Confirm) + if shouldRefreshLogin(err) { + clio.Debugw("prompting user login because token is expired", "error_details", err.Error()) + // NOTE(chrnorm): ideally we'll bubble up a more strongly typed error in future here, to avoid the string comparison on the error message. + + // the OAuth2.0 token is expired so we should prompt the user to log in + clio.Infof("You need to log in to Common Fate") + + lf := loginflow.NewFromConfig(cfg) + err = lf.Login(ctx) + if err != nil { + return false, nil, justActivated, err + } + + accessclient = access.NewFromConfig(cfg) + + // retry the Dry Run again + hasChanges, result, err = DryRun(ctx, apiURL, accessclient, &req, false, input.Confirm) + } + + if err != nil { + return false, nil, justActivated, err + } + if !hasChanges { + if result != nil && len(result.Grants) == 1 && result.Grants[0].Grant.Status == accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE { + return false, result, justActivated, nil + } + if input.Wait { + return true, result, justActivated, nil + } + // shouldn't retry assuming if there aren't any proposed access changes + return false, nil, justActivated, errors.New("no access changes") + } + + // if we get here, dry-run has passed the user has confirmed they want to proceed. + req.DryRun = false + + if input.Reason != "" { + req.Justification.Reason = &input.Reason + } else { + if result.Validation != nil && result.Validation.HasReason { + if !IsTerminal(os.Stdin.Fd()) { + return false, nil, justActivated, errors.New("detected a noninteractive terminal: a reason is required to make this access request, to apply the planned changes please re-run with the --reason flag") + } + + var customReason string + msg := "Reason for access (Required)" + reasonPrompt := &survey.Input{ + Message: msg, + Help: "Will be stored in audit trails and associated with your request", + } + withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) + err = survey.AskOne(reasonPrompt, &customReason, withStdio, survey.WithValidator(survey.Required)) + + if err != nil { + return false, nil, justActivated, err + } + + req.Justification.Reason = &customReason + } + } + + if len(input.Attachments) > 0 { + req.Justification.Attachments = grab.Map(input.Attachments, func(t string) *accessv1alpha1.AttachmentSpecifier { + return &accessv1alpha1.AttachmentSpecifier{ + Specify: &accessv1alpha1.AttachmentSpecifier_Lookup{ + Lookup: t, + }, + } + }) + } else { + if result.Validation != nil && result.Validation.HasJiraTicket { + if !IsTerminal(os.Stdin.Fd()) { + return false, nil, justActivated, errors.New("detected a noninteractive terminal: a jira ticket attachment is required to make this access request, to apply the planned changes please re-run with the --attach flag") + } + + var attachment string + msg := "Jira ticket attachment for access (Required)" + reasonPrompt := &survey.Input{ + Message: msg, + Help: "Will be stored in audit trails and associated with your request", + } + withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) + err = survey.AskOne(reasonPrompt, &attachment, withStdio, survey.WithValidator(survey.Required)) + + if err != nil { + return false, nil, justActivated, err + } + + req.Justification.Attachments = append(req.Justification.Attachments, &accessv1alpha1.AttachmentSpecifier{ + Specify: &accessv1alpha1.AttachmentSpecifier_Lookup{ + Lookup: attachment, + }, + }) + } + } + + // the spinner must be started after prompting for reason, otherwise the prompt gets hidden + si := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + si.Suffix = " ensuring access..." + si.Writer = os.Stderr + si.Start() + + res, err := accessclient.BatchEnsure(ctx, connect.NewRequest(&req)) + if err != nil { + si.Stop() + return false, nil, justActivated, err + } + si.Stop() + //prints response diag messages + printdiags.Print(res.Msg.Diagnostics, nil) + + clio.Debugw("BatchEnsure response", "response", res) + + names := map[eid.EID]string{} + + for _, g := range res.Msg.Grants { + names[eid.New("Access::Grant", g.Grant.Id)] = g.Grant.Name + + // default is to show the original duration, except for an active request, where it gets recalculated below to the time remaining + exp := ShortDur(g.Grant.Duration.AsDuration()) + + switch g.Change { + case accessv1alpha1.GrantChange_GRANT_CHANGE_ACTIVATED: + _, _ = color.New(color.BgHiGreen).Fprintf(os.Stderr, "[ACTIVATED]") + _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, " %s was activated for %s: %s\n", g.Grant.Name, exp, requestURL(apiURL, g.Grant)) + + retry = true + justActivated = true + + continue + + case accessv1alpha1.GrantChange_GRANT_CHANGE_EXTENDED: + extendedTime := "" + if g.Grant.Extension != nil { + extendedTime = ShortDur(g.Grant.Extension.ExtensionDurationSeconds.AsDuration()) + } + _, _ = color.New(color.BgBlue).Fprintf(os.Stderr, "[EXTENDED]") + _, _ = color.New(color.FgBlue).Fprintf(os.Stderr, " %s was extended for another %s: %s\n", g.Grant.Name, extendedTime, requestURL(apiURL, g.Grant)) + _, _ = color.New(color.FgGreen).Printf(" %s will now expire in %s\n", g.Grant.Name, exp) + + retry = true + + continue + + case accessv1alpha1.GrantChange_GRANT_CHANGE_REQUESTED: + _, _ = color.New(color.BgHiYellow, color.FgBlack).Fprintf(os.Stderr, "[REQUESTED]") + _, _ = color.New(color.FgYellow).Fprintf(os.Stderr, " %s requires approval: %s\n", g.Grant.Name, requestURL(apiURL, g.Grant)) + + if input.Wait { + return true, res.Msg, justActivated, nil + } + + return false, nil, justActivated, errors.New("applying access was attempted but the resources requested require approval before activation") + + case accessv1alpha1.GrantChange_GRANT_CHANGE_PROVISIONING_FAILED: + // shouldn't happen in the dry-run request but handle anyway + _, _ = color.New(color.FgRed).Fprintf(os.Stderr, "[ERROR] %s failed provisioning: %s\n", g.Grant.Name, requestURL(apiURL, g.Grant)) + + return false, nil, justActivated, errors.New("access provisioning failed") + } + + switch g.Grant.Status { + case accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE: + // work out how long is remaining on the active grant + exp = ShortDur(time.Until(g.Grant.ExpiresAt.AsTime())) + _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, "[ACTIVE] %s is already active for the next %s: %s\n", g.Grant.Name, exp, requestURL(apiURL, g.Grant)) + + retry = true + + continue + + case accessv1alpha1.GrantStatus_GRANT_STATUS_PENDING: + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[PENDING] %s is already pending: %s\n", g.Grant.Name, requestURL(apiURL, g.Grant)) + if input.Wait { + return true, res.Msg, justActivated, nil + } + return false, nil, justActivated, errors.New("access is pending approval") + + case accessv1alpha1.GrantStatus_GRANT_STATUS_CLOSED: + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[CLOSED] %s is closed but was still returned: %s\n. This is most likely due to an error in Common Fate and should be reported to our team: support@commonfate.io.", g.Grant.Name, requestURL(apiURL, g.Grant)) + + return false, nil, justActivated, errors.New("grant was closed") + + default: + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[UNSPECIFIED] %s is in an unspecified status: %s\n. This is most likely due to an error in Common Fate and should be reported to our team: support@commonfate.io.", g.Grant.Name, requestURL(apiURL, g.Grant)) + return false, nil, justActivated, errors.New("grant was in an unspecified state") + } + + } + + printdiags.Print(res.Msg.Diagnostics, names) + + return retry, res.Msg, justActivated, nil + +} + +func (h Hook) RetryAccess(ctx context.Context, input NoAccessInput) error { + cfg, err := cfcfg.Load(ctx, input.Profile) + if err != nil { + return err + } + + target := eid.New("AWS::Account", input.Profile.AWSConfig.SSOAccountID) + role := input.Profile.AWSConfig.SSORoleName + _, err = h.RetryNoEntitlementAccess(ctx, cfg, NoEntitlementAccessInput{ + Target: target.String(), + Role: role, + Reason: input.Reason, + Duration: input.Duration, + Confirm: input.Confirm, + Wait: input.Wait, + StartTime: input.StartTime, + Attachments: input.Attachments, + }) + return err +} + +func (h Hook) RetryNoEntitlementAccess(ctx context.Context, cfg *config.Context, input NoEntitlementAccessInput) (result *accessv1alpha1.BatchEnsureResponse, err error) { + + req := accessv1alpha1.BatchEnsureRequest{ + Entitlements: []*accessv1alpha1.EntitlementInput{ + { + Target: &accessv1alpha1.Specifier{ + Specify: &accessv1alpha1.Specifier_Lookup{ + Lookup: input.Target, + }, + }, + Role: &accessv1alpha1.Specifier{ + Specify: &accessv1alpha1.Specifier_Lookup{ + Lookup: input.Role, + }, + }, + Duration: input.Duration, + }, + }, + Justification: &accessv1alpha1.Justification{}, + } + accessclient := access.NewFromConfig(cfg) + res, err := accessclient.BatchEnsure(ctx, connect.NewRequest(&req)) + if err != nil { + return nil, err + } + + clio.Debugw("batch ensure response", "res", res.Msg) + + now := time.Now() + elapsed := now.Sub(input.StartTime).Round(time.Second * 10) + + allGrantsApproved := true + allGrantsActivated := true + for _, g := range res.Msg.Grants { + if g.Grant.Status == accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE { + continue + } + // if grant is approved but the change is unspecified then the user is not able to automatically activate + if g.Grant.Approved && g.Change == accessv1alpha1.GrantChange_GRANT_CHANGE_UNSPECIFIED && g.Grant.ProvisioningStatus != accessv1alpha1.ProvisioningStatus_PROVISIONING_STATUS_SUCCESSFUL { + clio.Infof("Request was approved but failed to activate, you might not have permission to activate. You can try and activate the access using the Common Fate web console. [%s elapsed]", elapsed) + printdiags.Print(res.Msg.Diagnostics, nil) + } + + if !g.Grant.Approved { + clio.Infof("Waiting for request to be approved... [%s elapsed]", elapsed) + allGrantsApproved = false + } + if g.Grant.ActivatedAt == nil { + allGrantsActivated = false + } + + } + // Note: the current behaviour of Common Fate BatchEnsure is that it only returns the grant that you asked for event when a request already exists with multiple + // grants, if this changes in the future, we would need to fix this logic to correctly identify the grant that the user requested + // for now this will work + if !allGrantsApproved || !allGrantsActivated { + return res.Msg, errors.New("waiting on all grants to be approved and activated") + } + return res.Msg, nil +} + +func requestURL(apiURL *url.URL, grant *accessv1alpha1.Grant) string { + p := apiURL.JoinPath("access", "requests", grant.AccessRequestId) + return p.String() +} + +func DryRun(ctx context.Context, apiURL *url.URL, client accessv1alpha1connect.AccessServiceClient, req *accessv1alpha1.BatchEnsureRequest, jsonOutput bool, confirm bool) (bool, *accessv1alpha1.BatchEnsureResponse, error) { + req.DryRun = true + + si := spinner.New(spinner.CharSets[14], 100*time.Millisecond) + si.Suffix = " planning access changes..." + si.Writer = os.Stderr + si.Start() + + res, err := client.BatchEnsure(ctx, connect.NewRequest(req)) + if err != nil { + si.Stop() + return false, nil, err + } + + si.Stop() + + clio.Debugw("BatchEnsure response", "response", res) + + if jsonOutput { + resJSON, err := protojson.Marshal(res.Msg) + if err != nil { + return false, nil, err + } + fmt.Println(string(resJSON)) + + return false, nil, errors.New("exiting because --output=json was specified: use --output=text to show an interactive prompt, or use --confirm to proceed with the changes") + } + + names := map[eid.EID]string{} + + var hasChanges bool + + for _, g := range res.Msg.Grants { + names[eid.New("Access::Grant", g.Grant.Id)] = g.Grant.Name + + // default is to show the original duration, except for an active request, where it gets recalculated below to the time remaining + exp := ShortDur(g.Grant.Duration.AsDuration()) + + if g.Change > 0 { + hasChanges = true + } + + switch g.Change { + case accessv1alpha1.GrantChange_GRANT_CHANGE_ACTIVATED: + _, _ = color.New(color.BgHiGreen).Fprintf(os.Stderr, "[WILL ACTIVATE]") + _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, " %s will be activated for %s: %s\n", g.Grant.Name, exp, requestURL(apiURL, g.Grant)) + continue + + case accessv1alpha1.GrantChange_GRANT_CHANGE_EXTENDED: + extendedTime := "" + if g.Grant.Extension != nil { + extendedTime = ShortDur(g.Grant.Extension.ExtensionDurationSeconds.AsDuration()) + } + _, _ = color.New(color.BgBlue).Printf("[WILL EXTEND]") + _, _ = color.New(color.FgBlue).Printf(" %s will be extended for another %s: %s\n", g.Grant.Name, extendedTime, requestURL(apiURL, g.Grant)) + continue + + case accessv1alpha1.GrantChange_GRANT_CHANGE_REQUESTED: + _, _ = color.New(color.BgHiYellow, color.FgBlack).Fprintf(os.Stderr, "[WILL REQUEST]") + _, _ = color.New(color.FgYellow).Fprintf(os.Stderr, " %s will require approval\n", g.Grant.Name) + continue + + case accessv1alpha1.GrantChange_GRANT_CHANGE_PROVISIONING_FAILED: + // shouldn't happen in the dry-run request but handle anyway + _, _ = color.New(color.FgRed).Fprintf(os.Stderr, "[ERROR] %s will fail provisioning\n", g.Grant.Name) + continue + } + + switch g.Grant.Status { + case accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE: + exp = ShortDur(time.Until(g.Grant.ExpiresAt.AsTime())) + _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, "[ACTIVE] %s is already active for the next %s: %s\n", g.Grant.Name, exp, requestURL(apiURL, g.Grant)) + continue + case accessv1alpha1.GrantStatus_GRANT_STATUS_PENDING: + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[PENDING] %s is already pending: %s\n", g.Grant.Name, requestURL(apiURL, g.Grant)) + continue + case accessv1alpha1.GrantStatus_GRANT_STATUS_CLOSED: + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[CLOSED] %s is closed but was still returned: %s\n. This is most likely due to an error in Common Fate and should be reported to our team: support@commonfate.io.", g.Grant.Name, requestURL(apiURL, g.Grant)) + continue + } + + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[UNSPECIFIED] %s is in an unspecified status: %s\n. This is most likely due to an error in Common Fate and should be reported to our team: support@commonfate.io.", g.Grant.Name, requestURL(apiURL, g.Grant)) + } + + printdiags.Print(res.Msg.Diagnostics, names) + + if !hasChanges { + return false, res.Msg, nil + } + + if !confirm { + if !IsTerminal(os.Stdin.Fd()) { + return false, nil, errors.New("detected a noninteractive terminal: to apply the planned changes please re-run with the --confirm flag") + } + + withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) + confirmPrompt := survey.Confirm{ + Message: "Apply proposed access changes", + } + err = survey.AskOne(&confirmPrompt, &confirm, withStdio) + if err != nil { + return false, nil, err + } + } + + if !confirm { + return false, nil, errors.New("cancelled operation") + } + + clio.Info("Attempting to grant access...") + return confirm, res.Msg, nil +} + +func IsTerminal(fd uintptr) bool { + return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) +} + +func ShortDur(d time.Duration) string { + if d > time.Minute { + d = d.Round(time.Minute) + } else { + d = d.Round(time.Second) + } + + s := d.String() + if strings.HasSuffix(s, "m0s") { + s = s[:len(s)-2] + } + if strings.HasSuffix(s, "h0m") { + s = s[:len(s)-2] + } + return s +} + +func shouldRefreshLogin(err error) bool { + if err == nil { + return false + } + if strings.Contains(err.Error(), "oauth2: token expired") { + return true + } + if strings.Contains(err.Error(), "oauth2: invalid grant") { + return true + } + // Sanity check that error message is matching correctly + if strings.Contains(err.Error(), `oauth2: "token_expired"`) { + return true + } + if strings.Contains(err.Error(), `oauth2: "invalid_grant"`) { + return true + } + + return false +} From 555a3ac5c40919e204d6a6da9f9ba7964f5b114d Mon Sep 17 00:00:00 2001 From: BillyJBryant <3013565+billyjbryant@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:57:23 -0700 Subject: [PATCH 2/7] refactor: remove CF-specific proxy/eks/rds/console packages Strip out CommonFate infrastructure-specific packages that require a server-side proxy component. These are not relevant to the provider-agnostic integration and will be restored in a future version (JIT-109) when JITSudo implements server-side proxy support. Removed packages: - pkg/granted/proxy/ (SSM port forwarding + yamux multiplexing) - pkg/granted/eks/ (EKS proxy command) - pkg/granted/rds/ (RDS proxy command) - pkg/granted/exp/ (experimental request UI) - pkg/granted/cf.go (common-fate console command) Kept packages: - pkg/hook/accessrequesthook/ (NoAccess hook - being generalized) - pkg/granted/registry/cfregistry/ (HTTP registry - being generalized) - pkg/cfcfg/ (provider config - being generalized) - pkg/granted/request/ (CLI request commands) - pkg/granted/auth/ (CLI auth commands) Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 43 +- go.sum | 95 +-- pkg/granted/cf.go | 97 --- pkg/granted/eks/config.go | 93 --- pkg/granted/eks/eks.go | 169 ------ pkg/granted/entrypoint.go | 7 - pkg/granted/exp/exp.go | 16 - pkg/granted/exp/request/request.go | 758 ------------------------ pkg/granted/proxy/ensureaccess.go | 127 ---- pkg/granted/proxy/initiateconnection.go | 55 -- pkg/granted/proxy/listenandproxy.go | 99 ---- pkg/granted/proxy/ports.go | 37 -- pkg/granted/proxy/prompt.go | 83 --- pkg/granted/proxy/proxy.go | 160 ----- pkg/granted/proxy/ssm_logger.go | 101 ---- pkg/granted/proxy/writers.go | 30 - pkg/granted/rds/local_port.go | 31 - pkg/granted/rds/local_port_test.go | 56 -- pkg/granted/rds/rds.go | 207 ------- 19 files changed, 5 insertions(+), 2259 deletions(-) delete mode 100644 pkg/granted/cf.go delete mode 100644 pkg/granted/eks/config.go delete mode 100644 pkg/granted/eks/eks.go delete mode 100644 pkg/granted/exp/exp.go delete mode 100644 pkg/granted/exp/request/request.go delete mode 100644 pkg/granted/proxy/ensureaccess.go delete mode 100644 pkg/granted/proxy/initiateconnection.go delete mode 100644 pkg/granted/proxy/listenandproxy.go delete mode 100644 pkg/granted/proxy/ports.go delete mode 100644 pkg/granted/proxy/prompt.go delete mode 100644 pkg/granted/proxy/proxy.go delete mode 100644 pkg/granted/proxy/ssm_logger.go delete mode 100644 pkg/granted/proxy/writers.go delete mode 100644 pkg/granted/rds/local_port.go delete mode 100644 pkg/granted/rds/local_port_test.go delete mode 100644 pkg/granted/rds/rds.go diff --git a/go.mod b/go.mod index 198e1196..042e2659 100644 --- a/go.mod +++ b/go.mod @@ -20,45 +20,31 @@ require ( require ( connectrpc.com/connect v1.14.0 github.com/alessio/shellescape v1.4.2 - github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 - github.com/aws/session-manager-plugin v0.0.0-20240702185740-6384c679ead7 github.com/briandowns/spinner v1.23.0 - github.com/charmbracelet/lipgloss v0.13.0 github.com/common-fate/cli v1.8.0 github.com/common-fate/clio v1.2.3 - github.com/common-fate/common-fate v0.15.13 github.com/common-fate/glide-cli v0.6.0 github.com/common-fate/grab v1.3.0 github.com/common-fate/sdk v1.71.0 - github.com/common-fate/xid v1.0.0 github.com/fatih/color v1.16.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-version v1.7.0 - github.com/hashicorp/yamux v0.1.2 - github.com/lithammer/fuzzysearch v1.1.5 - github.com/mattn/go-runewidth v0.0.16 github.com/schollz/progressbar/v3 v3.13.1 go.uber.org/zap v1.26.0 google.golang.org/protobuf v1.34.2 gopkg.in/yaml.v3 v3.0.1 - k8s.io/client-go v0.28.4 ) require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect - github.com/aws/aws-sdk-go v1.54.19 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/benbjohnson/clock v1.3.5 // indirect - github.com/charmbracelet/x/ansi v0.2.3 // indirect - github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect + github.com/common-fate/common-fate v0.15.13 // indirect github.com/common-fate/iso8601 v1.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/deepmap/oapi-codegen v1.11.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/getkin/kin-openapi v0.107.0 // indirect github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect @@ -66,37 +52,26 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/swag v0.22.4 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/gofuzz v1.2.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.11 // indirect github.com/invopop/yaml v0.2.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect github.com/muhlemmer/gu v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.3.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/twinj/uuid v0.0.0-20151029044442-89173bcdda19 // indirect - github.com/x448/float16 v0.8.4 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - github.com/xtaci/smux v1.5.24 // indirect github.com/zitadel/logging v0.6.0 // indirect github.com/zitadel/oidc/v3 v3.26.0 // indirect github.com/zitadel/schema v1.3.0 // indirect @@ -105,17 +80,8 @@ require ( go.opentelemetry.io/otel/trace v1.28.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/time v0.3.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect ) require ( @@ -154,7 +120,6 @@ require ( golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 gopkg.in/ini.v1 v1.67.0 - k8s.io/apimachinery v0.31.1 // indirect ) replace github.com/aws/session-manager-plugin => github.com/common-fate/session-manager-plugin v0.0.0-20240723053832-3d311db99016 diff --git a/go.sum b/go.sum index 66ed9380..c8a0998c 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,6 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= -github.com/aws/aws-sdk-go v1.54.19 h1:tyWV+07jagrNiCcGRzRhdtVjQs7Vy41NwsuOcl0IbVI= -github.com/aws/aws-sdk-go v1.54.19/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= @@ -40,8 +38,6 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1x github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk= -github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= -github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w= github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWAXLGFIizeqkdkKgRlJwWc= @@ -50,20 +46,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= -github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= -github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= -github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 h1:kHaBemcxl8o/pQ5VM1c8PVE1PubbNx3mjUr09OqWGCs= -github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575/go.mod h1:9d6lWj8KzO/fd/NrVaLscBKmPigpZpn5YawRPw+e3Yo= github.com/common-fate/awsconfigfile v0.10.0 h1:9W0JTeO0d3jNLw3Ps9U7IJwLYp4D9zcipq/sqNEWJOg= github.com/common-fate/awsconfigfile v0.10.0/go.mod h1:znstvN26aO+KUwmdjwZ+WcmitZ7heEJb5iFdCPokAO8= github.com/common-fate/cli v1.8.0 h1:T3I+NCMTyvIlZC8QK9qfmsZWj3eSDSZRPHQlM5KJ8Q4= @@ -80,14 +68,10 @@ github.com/common-fate/iso8601 v1.1.0 h1:nrej9shsK1aB4IyOAjZl68xGk8yDuUxVwQjoDzx github.com/common-fate/iso8601 v1.1.0/go.mod h1:DU4mvUEkkWZUUSJq2aCuNqM1luSb0Pwyb2dLzXS+img= github.com/common-fate/sdk v1.71.0 h1:SA+KZdbkOWBR6SrTculoUlALAGj6ftULdUPgr3Yw7RY= github.com/common-fate/sdk v1.71.0/go.mod h1:OrXhzB2Y1JSrKGHrb4qRmY+6MF2M3MFb+3edBnessXo= -github.com/common-fate/session-manager-plugin v0.0.0-20240723053832-3d311db99016 h1:WObxQKT/BuR8HWKSGsJ6aQb/cdhvkenkb1KWXNyPWeE= -github.com/common-fate/session-manager-plugin v0.0.0-20240723053832-3d311db99016/go.mod h1:glAZTUB+4Eg0JVLC3B/YEomJv6QHcNS3klJjw9HC5Y8= github.com/common-fate/updatecheck v0.3.5 h1:UGIKMnYwuHjbhhCaisLz1pNPg8Z1nXEoWcfqT+4LkAg= github.com/common-fate/updatecheck v0.3.5/go.mod h1:fru9yoUXmM3QVAUdDDqKQeDoln20Pkji/7EH64gVHMs= github.com/common-fate/useragent v0.1.0 h1:RLmkIiJXcOUJAUyXWc/zCaGbrGmlCbHBGMx99ztQ3ZU= github.com/common-fate/useragent v0.1.0/go.mod h1:GjXGR6cDiMboDP04qlfDfA5HTbeoRSoNgQWDAyOdW9o= -github.com/common-fate/xid v1.0.0 h1:G1goIvujOPfeuH7p7ibvu585QE10vHsjka8YjD8Qd1o= -github.com/common-fate/xid v1.0.0/go.mod h1:F4G+xicQxSTFfsVzbqopLRyxWmGswnbmODvmDo4Jivo= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -107,16 +91,10 @@ github.com/deepmap/oapi-codegen v1.11.0 h1:f/X2NdIkaBKsSdpeuwLnY/vDI0AtPUrmB5LMg github.com/deepmap/oapi-codegen v1.11.0/go.mod h1:k+ujhoQGxmQYBZBbxhOZNZf4j08qv5mC+OH+fFTnKxM= github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA= github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= -github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= -github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/getkin/kin-openapi v0.94.0/go.mod h1:LWZfzOd7PRy8GJ1dJ6mCU6tNdSfOwRac1BUPam4aw6Q= github.com/getkin/kin-openapi v0.107.0 h1:bxhL6QArW7BXQj8NjXfIJQy680NsMKd25nwhvpCXchg= github.com/getkin/kin-openapi v0.107.0/go.mod h1:9Dhr+FasATJZjS4iOLvB0hkaxgYdulrNYm2e9epLWOo= @@ -136,8 +114,6 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= @@ -153,19 +129,12 @@ github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4 github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -178,16 +147,12 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= -github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= @@ -199,22 +164,15 @@ github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc= github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= -github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= -github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= -github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= -github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -236,10 +194,6 @@ github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbq github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= github.com/lestrrat-go/jwx v1.2.24/go.mod h1:zoNuZymNl5lgdcu6P7K6ie2QRll5HVfF4xwxBBK1NxY= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/lithammer/fuzzysearch v1.1.5 h1:Ag7aKU08wp0R9QCfF4GoGST9HbmAIeLP7xwMrOBEp1c= -github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -272,23 +226,17 @@ github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFW github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= -github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= @@ -305,6 +253,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= @@ -323,8 +272,6 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -341,8 +288,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/twinj/uuid v0.0.0-20151029044442-89173bcdda19 h1:HlxV0XiEKMMyjS3gGtJmmFZsxQ22GsLvA7F980il+1w= -github.com/twinj/uuid v0.0.0-20151029044442-89173bcdda19/go.mod h1:mMgcE1RHFUFqe5AfiwlINXisXfDGro23fWdPUfOMjRY= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= @@ -351,14 +296,8 @@ github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -github.com/xtaci/smux v1.5.24 h1:77emW9dtnOxxOQ5ltR+8BbsX1kzcOxQ5gB+aaV9hXOY= -github.com/xtaci/smux v1.5.24/go.mod h1:OMlQbT5vcgl2gb49mFkYo6SMf+zP3rcjcwQz7ZU7IGY= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank= @@ -384,7 +323,6 @@ go.uber.org/ratelimit v0.3.0/go.mod h1:So5LG7CV1zWpY1sHe+DXTJqQvOx+FFPFaAs2SnoyB go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -394,14 +332,10 @@ golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -413,8 +347,6 @@ golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= @@ -423,7 +355,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -460,12 +391,8 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -485,8 +412,6 @@ gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= @@ -502,21 +427,3 @@ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.28.4 h1:8ZBrLjwosLl/NYgv1P7EQLqoO8MGQApnbgH8tu3BMzY= -k8s.io/api v0.28.4/go.mod h1:axWTGrY88s/5YE+JSt4uUi6NMM+gur1en2REMR7IRj0= -k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= -k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= -k8s.io/client-go v0.28.4 h1:Np5ocjlZcTrkyRJ3+T3PkXDpe4UpatQxj85+xjaD2wY= -k8s.io/client-go v0.28.4/go.mod h1:0VDZFpgoZfelyP5Wqu0/r/TRYcLYuJ2U1KEeoaPa1N4= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= -k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= -k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/pkg/granted/cf.go b/pkg/granted/cf.go deleted file mode 100644 index 3ccd4a64..00000000 --- a/pkg/granted/cf.go +++ /dev/null @@ -1,97 +0,0 @@ -package granted - -import ( - "errors" - "fmt" - - "github.com/AlecAivazis/survey/v2" - "github.com/common-fate/clio" - "github.com/fwdcloudsec/granted/pkg/cfaws" - "github.com/fwdcloudsec/granted/pkg/cfcfg" - sdkconfig "github.com/common-fate/sdk/config" - "github.com/pkg/browser" - "github.com/urfave/cli/v2" -) - -var CFCommand = cli.Command{ - Name: "common-fate", - Aliases: []string{"cf"}, - Usage: "Interact with your Common Fate deployment", - Subcommands: []*cli.Command{&ConsoleCommand}, -} - -var CFConsoleCommand = cli.Command{ - Name: "console", - Usage: "Open the Common Fate web console", - Flags: []cli.Flag{&cli.StringFlag{Name: "profile", Usage: "Open the Common Fate web console for a specific profile"}}, - Action: func(c *cli.Context) error { - - ctx := c.Context - consoleURL := "" - profiles, err := cfaws.LoadProfiles() - if err != nil { - return err - } - - profileName := c.String("profile") - if profileName != "" { - p, err := profiles.Profile(profileName) - if err != nil { - return err - } - url, err := cfcfg.GetCommonFateURL(p) - if err != nil { - return err - } - if url == nil { - return errors.New("the profile exists but it is not configured with with a Common Fate console url") - } - consoleURL = url.String() - } else { - foundStartURLs := map[string]bool{} - for _, profile := range profiles.ProfileNames { - p, err := profiles.Profile(profile) - if err != nil { - return err - } - url, err := cfcfg.GetCommonFateURL(p) - if err != nil { - clio.Debug(err) - } - if url != nil { - foundStartURLs[url.String()] = true - } - } - keys := make([]string, 0, len(foundStartURLs)) - for k := range foundStartURLs { - keys = append(keys, k) - } - if len(keys) == 0 { - // fall back to the config file - cfFileConfig, err := sdkconfig.LoadDefault(ctx) - if err != nil { - clio.Debug(fmt.Errorf("could not load profile from config file: %w", err)) - return errors.New("no Common Fate deployment urls found in your aws config or the default config file, you can setup now with 'granted login'") - } - consoleURL = cfFileConfig.APIURL - } - if len(keys) == 1 { - consoleURL = keys[0] - } - - err = survey.AskOne(&survey.Select{ - Message: "Please select which Common Fate deployment you would like to open: ", - Options: keys, - }, &consoleURL) - if err != nil { - return err - } - - } - - clio.Infof("Opening the Common Fate console (%s) in your default browser...", consoleURL) - - // uses the default browser to open the console - return browser.OpenURL(consoleURL) - }, -} diff --git a/pkg/granted/eks/config.go b/pkg/granted/eks/config.go deleted file mode 100644 index 467363c7..00000000 --- a/pkg/granted/eks/config.go +++ /dev/null @@ -1,93 +0,0 @@ -package eks - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/common-fate/clio" - "github.com/fwdcloudsec/granted/pkg/granted/proxy" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - "github.com/fatih/color" - "k8s.io/client-go/tools/clientcmd" - "k8s.io/client-go/tools/clientcmd/api" -) - -func OpenKubeConfig() (*api.Config, string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, "", err - } - - kubeConfigPath := filepath.Join(homeDir, ".kube", "config") - - loader := clientcmd.ClientConfigLoadingRules{ - Precedence: []string{kubeConfigPath}, - WarnIfAllMissing: true, - Warner: func(err error) { - // debug log the warning if teh file does not exist - // it will default to creating a new file - clio.Debug(err) - }, - } - config, err := loader.Load() - if err != nil { - return nil, "", err - } - - return config, kubeConfigPath, nil -} - -func AddContextToConfig(ensureAccessOutput *proxy.EnsureAccessOutput[*accessv1alpha1.AWSEKSProxyOutput], port int) error { - - kc, kubeConfigPath, err := OpenKubeConfig() - if err != nil { - return err - } - - clusterContextName := fmt.Sprintf("cf-grant-to-%s-as-%s", ensureAccessOutput.GrantOutput.EksCluster.Name, ensureAccessOutput.GrantOutput.ServiceAccountName) - // Use the same name for the context and the cluster, so that each grant is assigned a unique entry for the cluster - clusterName := clusterContextName - - username := ensureAccessOutput.GrantOutput.ServiceAccountName - - // remove an existing value for the context being added/updated - delete(kc.Contexts, clusterContextName) - // remove existing cluster definitions so they can be reset - delete(kc.Clusters, clusterName) - // remove existing user definitions so they can be reset - delete(kc.AuthInfos, username) - - newCluster := api.NewCluster() - newCluster.Server = fmt.Sprintf("http://localhost:%d", port) - newCluster.InsecureSkipTLSVerify = true - //add the new cluster and context back in - kc.Clusters[clusterName] = newCluster - - newContext := api.NewContext() - newContext.Cluster = clusterName - newContext.AuthInfo = username - // @TODO, teams may wish to specify a default namespace for each user or cluster? - newContext.Namespace = "default" - kc.Contexts[clusterContextName] = newContext - - newUser := api.NewAuthInfo() - newUser.Impersonate = username - kc.AuthInfos[username] = newUser - - err = clientcmd.WriteToFile(*kc, kubeConfigPath) - if err != nil { - return err - } - - //set the context - clio.Infof("EKS proxy is ready for connections") - clio.Infof("Your `~/.kube/config` file has been updated with a new cluster context. To connect to this cluster, run the following command to switch your current context:") - clio.Log(color.YellowString("kubectl config use-context %s", clusterContextName)) - clio.NewLine() - clio.Infof("Or using the --context flag with kubectl: %s", color.YellowString("kubectl --context=%s", clusterContextName)) - clio.NewLine() - - return nil - -} diff --git a/pkg/granted/eks/eks.go b/pkg/granted/eks/eks.go deleted file mode 100644 index 79b3237f..00000000 --- a/pkg/granted/eks/eks.go +++ /dev/null @@ -1,169 +0,0 @@ -package eks - -import ( - "context" - "errors" - - "connectrpc.com/connect" - - "github.com/common-fate/clio" - "github.com/common-fate/grab" - "github.com/fwdcloudsec/granted/pkg/cfcfg" - "github.com/fwdcloudsec/granted/pkg/granted/proxy" - "github.com/common-fate/sdk/config" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - "github.com/common-fate/sdk/service/access" - - "github.com/urfave/cli/v2" -) - -var Command = cli.Command{ - Name: "eks", - Usage: "Granted EKS plugin", - Description: "Granted EKS plugin", - Subcommands: []*cli.Command{&proxyCommand}, -} - -// isLocalMode is used where some behaviour needs to be changed to run against a local development proxy server -func isLocalMode(c *cli.Context) bool { - return c.String("mode") == "local" -} - -var proxyCommand = cli.Command{ - Name: "proxy", - Usage: "The Proxy plugin is used in conjunction with a Commnon Fate deployment to request temporary access to an AWS EKS Cluster", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "target", Aliases: []string{"cluster"}}, - &cli.StringFlag{Name: "role", Aliases: []string{"service-account"}}, - &cli.StringFlag{Name: "reason", Usage: "Provide a reason for requesting access to the role"}, - &cli.StringSliceFlag{Name: "attach", Usage: "Attach justifications to your request, such as a Jira ticket id or url `--attach=TP-123`"}, - &cli.BoolFlag{Name: "confirm", Aliases: []string{"y"}, Usage: "Skip confirmation prompts for access requests"}, - &cli.BoolFlag{Name: "wait", Value: true, Usage: "Wait for the access request to be approved."}, - &cli.BoolFlag{Name: "no-cache", Usage: "Disables caching of session credentials and forces a refresh", EnvVars: []string{"GRANTED_NO_CACHE"}}, - &cli.DurationFlag{Name: "duration", Aliases: []string{"d"}, Usage: "The duration for your access request"}, - &cli.StringFlag{Name: "mode", Hidden: true, Usage: "What mode to run the proxy command in, [remote,local], local is used in development to connect to a local instance of the proxy server rather than remote via SSM", Value: "remote"}, - }, - Action: func(c *cli.Context) error { - ctx := c.Context - cfg, err := config.LoadDefault(ctx) - if err != nil { - return err - } - - err = cfg.Initialize(ctx, config.InitializeOpts{}) - if err != nil { - return err - } - - ensuredAccess, err := proxy.EnsureAccess(ctx, cfg, proxy.EnsureAccessInput[*accessv1alpha1.AWSEKSProxyOutput]{ - Target: c.String("target"), - Role: c.String("role"), - Duration: c.Duration("duration"), - Reason: c.String("reason"), - Attachments: c.StringSlice("attach"), - Confirm: c.Bool("confirm"), - Wait: c.Bool("wait"), - PromptForEntitlement: promptForClusterAndRole, - GetGrantOutput: func(msg *accessv1alpha1.GetGrantOutputResponse) (*accessv1alpha1.AWSEKSProxyOutput, error) { - output := msg.GetOutputAwsEksProxy() - if output == nil { - return nil, errors.New("unexpected grant output, this indicates an error in the Common Fate Provisioning process, you should contect your Common Fate administrator") - } - return output, nil - }, - }) - if err != nil { - return err - } - - requestURL, err := cfcfg.GenerateRequestURL(cfg.APIURL, ensuredAccess.Grant.AccessRequestId) - if err != nil { - return err - } - - serverPort, localPort, err := proxy.Ports(isLocalMode(c)) - if err != nil { - return err - } - - clio.Debugw("prepared ports for access", "serverPort", serverPort, "localPort", localPort) - // In local mode ssm is not used, instead, the command connects directly to the proxy service running in local dev - // Return early because there is nothing to startup - if !isLocalMode(c) { - err = proxy.WaitForSSMConnectionToProxyServer(ctx, proxy.WaitForSSMConnectionToProxyServerOpts{ - AWSConfig: proxy.AWSConfig{ - SSOAccountID: ensuredAccess.GrantOutput.EksCluster.AccountId, - SSORoleName: ensuredAccess.GrantOutput.SsoRoleName, - SSORegion: ensuredAccess.GrantOutput.SsoRegion, - SSOStartURL: ensuredAccess.GrantOutput.SsoStartUrl, - Region: ensuredAccess.GrantOutput.EksCluster.Region, - SSMSessionTarget: ensuredAccess.GrantOutput.SsmSessionTarget, - NoCache: c.Bool("no-cache"), - }, - DisplayOpts: proxy.DisplayOpts{ - Command: "aws eks proxy", - SessionType: "EKS Proxy", - }, - ConnectionOpts: proxy.ConnectionOpts{ - ServerPort: serverPort, - LocalPort: localPort, - }, - GrantID: ensuredAccess.Grant.Id, - RequestID: ensuredAccess.Grant.AccessRequestId, - }) - if err != nil { - return err - } - } - - // Rather than the user having to specify a port via a flag, the proxy command just grabs an unused port to use. - // it means that each time you run the - tempPort, err := proxy.GrabUnusedPort() - if err != nil { - return err - } - - underlyingProxyServerConn, yamuxStreamConnection, err := proxy.InitiateSessionConnection(cfg, proxy.InitiateSessionConnectionInput{ - GrantID: ensuredAccess.Grant.Id, - RequestURL: requestURL, - LocalPort: localPort, - }) - if err != nil { - return err - } - defer func() { _ = underlyingProxyServerConn.Close() }() - defer func() { _ = yamuxStreamConnection.Close() }() - - err = AddContextToConfig(ensuredAccess, tempPort) - if err != nil { - return err - } - - return proxy.ListenAndProxy(ctx, yamuxStreamConnection, tempPort, requestURL) - }, -} - -// promptForClusterAndRole lists all available eks cluster entitlements for the user and displays a table selector UI -func promptForClusterAndRole(ctx context.Context, cfg *config.Context) (*accessv1alpha1.Entitlement, error) { - accessClient := access.NewFromConfig(cfg) - entitlements, err := grab.AllPages(ctx, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Entitlement, *string, error) { - res, err := accessClient.QueryEntitlements(ctx, connect.NewRequest(&accessv1alpha1.QueryEntitlementsRequest{ - PageToken: grab.Value(nextToken), - TargetType: grab.Ptr("AWS::EKS::Cluster"), - })) - if err != nil { - return nil, nil, err - } - return res.Msg.Entitlements, &res.Msg.NextPageToken, nil - }) - if err != nil { - return nil, err - } - - // check here to avoid nil pointer errors later - if len(entitlements) == 0 { - return nil, errors.New("you don't have access to any EKS Clusters") - } - - return proxy.PromptEntitlements(entitlements, "Cluster", "Service Account", "Select a cluster to connect to: ") -} diff --git a/pkg/granted/entrypoint.go b/pkg/granted/entrypoint.go index 4cf289b8..cf65f4d4 100644 --- a/pkg/granted/entrypoint.go +++ b/pkg/granted/entrypoint.go @@ -15,10 +15,7 @@ import ( "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/granted/auth" "github.com/fwdcloudsec/granted/pkg/granted/doctor" - "github.com/fwdcloudsec/granted/pkg/granted/eks" - "github.com/fwdcloudsec/granted/pkg/granted/exp" "github.com/fwdcloudsec/granted/pkg/granted/middleware" - "github.com/fwdcloudsec/granted/pkg/granted/rds" "github.com/fwdcloudsec/granted/pkg/granted/registry" "github.com/fwdcloudsec/granted/pkg/granted/request" "github.com/fwdcloudsec/granted/pkg/granted/settings" @@ -58,14 +55,10 @@ func GetCliApp() *cli.App { ®istry.ProfileRegistryCommand, &ConsoleCommand, &login, - &exp.Command, &CacheCommand, &auth.Command, &request.Command, &doctor.Command, - &rds.Command, - &CFCommand, - &eks.Command, }, // Granted may be invoked via our browser extension, which uses the Native Messaging // protocol to communicate with the Granted CLI. If invoked this way, the browser calls diff --git a/pkg/granted/exp/exp.go b/pkg/granted/exp/exp.go deleted file mode 100644 index ed0efba6..00000000 --- a/pkg/granted/exp/exp.go +++ /dev/null @@ -1,16 +0,0 @@ -// Package exp holds experimental commands. -// The API and arguments of these these commands are subject to change. -package exp - -import ( - "github.com/fwdcloudsec/granted/pkg/granted/exp/request" - "github.com/urfave/cli/v2" -) - -var Command = cli.Command{ - Name: "experimental", - Aliases: []string{"exp"}, - Subcommands: []*cli.Command{ - &request.Command, - }, -} diff --git a/pkg/granted/exp/request/request.go b/pkg/granted/exp/request/request.go deleted file mode 100644 index 71aee1ee..00000000 --- a/pkg/granted/exp/request/request.go +++ /dev/null @@ -1,758 +0,0 @@ -package request - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "os" - "path" - "path/filepath" - "strings" - "time" - - "github.com/AlecAivazis/survey/v2" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/briandowns/spinner" - "github.com/common-fate/awsconfigfile" - "github.com/common-fate/clio" - "github.com/common-fate/clio/clierr" - "github.com/common-fate/common-fate/pkg/types" - "github.com/common-fate/glide-cli/pkg/client" - cfconfig "github.com/common-fate/glide-cli/pkg/config" - "github.com/common-fate/glide-cli/pkg/profilesource" - "github.com/fwdcloudsec/granted/pkg/accessrequest" - "github.com/fwdcloudsec/granted/pkg/cache" - "github.com/fwdcloudsec/granted/pkg/cfaws" - grantedConfig "github.com/fwdcloudsec/granted/pkg/config" - "github.com/fwdcloudsec/granted/pkg/frecency" - "github.com/fwdcloudsec/granted/pkg/securestorage" - "github.com/hako/durafmt" - "github.com/lithammer/fuzzysearch/fuzzy" - "github.com/pkg/errors" - "github.com/urfave/cli/v2" - "golang.org/x/sync/errgroup" - "golang.org/x/text/cases" - "golang.org/x/text/language" - "gopkg.in/ini.v1" -) - -var Command = cli.Command{ - Name: "request", - Usage: "Request access to a role", - Subcommands: []*cli.Command{ - &awsCommand, - &latestCommand, - }, -} - -var awsCommand = cli.Command{ - Name: "aws", - Usage: "Request access to an AWS role", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "account", Usage: "The AWS account ID"}, - &cli.StringFlag{Name: "role", Usage: "The AWS role"}, - &cli.StringFlag{Name: "reason", Usage: "A reason for access"}, - &cli.DurationFlag{Name: "duration", Usage: "Duration of request, defaults to max duration of the access rule."}, - }, - Action: func(c *cli.Context) error { - return requestAccess(c.Context, requestAccessOpts{ - account: c.String("account"), - role: c.String("role"), - reason: c.String("reason"), - duratiuon: c.Duration("duration"), - }) - }, -} - -var latestCommand = cli.Command{ - Name: "latest", - Usage: "Request access to the latest AWS role you attempted to use", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "reason", Usage: "A reason for access"}, - &cli.DurationFlag{Name: "duration", Usage: "Duration of request, defaults to max duration of the access rule."}, - }, - Action: func(c *cli.Context) error { - role, err := accessrequest.LatestRole() - if err != nil { - return err - } - - clio.Infof("requesting access to account %s with role %s", role.Account, role.Role) - - return requestAccess(c.Context, requestAccessOpts{ - account: role.Account, - role: role.Role, - reason: c.String("reason"), - duratiuon: c.Duration("duration"), - }) - }, -} - -type requestAccessOpts struct { - account string - role string - reason string - duratiuon time.Duration -} - -func requestAccess(ctx context.Context, opts requestAccessOpts) error { - - cfcfg, err := cfconfig.Load() - if err != nil { - return err - } - - k, err := securestorage.NewCF().Storage.Keyring() - if err != nil { - return errors.Wrap(err, "loading keyring") - } - - // creates the Common Fate API client - cf, err := client.FromConfig(ctx, cfcfg, client.WithKeyring(k), client.WithLoginHint("granted login")) - if err != nil { - return err - } - - depID := cfcfg.CurrentOrEmpty().DashboardURL - - accounts, existingRules, accessRulesForAccount, err := RefreshCachedAccessRules(ctx, depID, cf) - if err != nil { - return err - } - - gConf, err := grantedConfig.Load() - if err != nil { - return errors.Wrap(err, "unable to load granted config") - } - - if gConf.CommonFateDefaultSSORegion == "" || gConf.CommonFateDefaultSSOStartURL == "" { - clio.Info("We need to do some once-off set up so that we can automatically populate your AWS config file (~/.aws/config) with the latest profiles after an Access Request is approved") - } - - if gConf.CommonFateDefaultSSORegion == "" { - p := &survey.Input{ - Message: "Your AWS SSO region:", - Help: "The AWS region that your IAM Identity Center instance is hosted in.", - } - err = survey.AskOne(p, &gConf.CommonFateDefaultSSORegion) - if err != nil { - return err - } - err = gConf.Save() - if err != nil { - return err - } - } - - if gConf.CommonFateDefaultSSOStartURL == "" { - p := &survey.Input{ - Message: "Your AWS SSO Start URL:", - Help: "The sign in URL for AWS SSO (e.g. 'https://example.awsapps.com/start')", - } - err = survey.AskOne(p, &gConf.CommonFateDefaultSSOStartURL) - if err != nil { - return err - } - err = gConf.Save() - if err != nil { - return err - } - } - - // a mapping of the selected survey prompt option, back to the actual value - // e.g. "my-account-name (123456789012)" -> 123456789012 - selectedAccountMap := map[string]string{} - var accountOptions []string - for _, a := range accounts { - option := fmt.Sprintf("%s (%s)", a.Label, a.Value) - accountOptions = append(accountOptions, option) - selectedAccountMap[option] = a.Value - } - - var selectedAccountOption string - selectedAccountID := opts.account - - if selectedAccountID == "" { - clio.Debugw("prompting for accounts", "accounts", accounts) - - prompt := &survey.Select{ - Message: "Account", - Options: accountOptions, - } - err = survey.AskOne(prompt, &selectedAccountOption) - if err != nil { - return err - } - - selectedAccountID = selectedAccountMap[selectedAccountOption] - } - - selectedAccountInfo, ok := accounts[selectedAccountID] - if !ok { - clio.Info("account not found in cache, refreshing cache...") - - err = clearCachedAccessRules(depID) - if err != nil { - return err - } - - accounts, _, accessRulesForAccount, err = RefreshCachedAccessRules(ctx, depID, cf) - if err != nil { - return err - } - selectedAccountID := opts.account - - selectedAccountInfo, ok = accounts[selectedAccountID] - - if !ok { - return clierr.New(fmt.Sprintf("account %s not found", selectedAccountID), clierr.Info("run 'granted exp request aws' to see a list of available accounts")) - } - - } - - ruleIDs := accessRulesForAccount[selectedAccountID] - - // note: we use a map here to de-duplicate accounts. - // this means that the RuleID in the accounts map is not necessarily - // the *only* Access Rule which grants access to that account. - permissionSets := map[string]cache.AccessTarget{} - - for _, rule := range existingRules { - if _, ok := ruleIDs[rule.ID]; !ok { - continue - } - - for _, t := range rule.Targets { - if t.Type != "permissionSetArn" { - continue - } - - permissionSets[t.Value] = t - } - } - - // map of permission set option label to Access Rule ID - // AdminAccess -> {"rul_123": true} - permissionSetRuleIDs := map[string]map[string]bool{} - - // map of permission set option label to permission set value - permissionSetValues := map[string]string{} - - var permissionSetOptions []string - for _, a := range permissionSets { - permissionSetOptions = append(permissionSetOptions, a.Label) // label only for permission sets (the ARN is difficult to interpret and the labels are unique) - - if _, ok := permissionSetRuleIDs[a.Label]; !ok { - permissionSetRuleIDs[a.Label] = map[string]bool{} - } - - permissionSetRuleIDs[a.Label][a.RuleID] = true - permissionSetValues[a.Label] = a.Value - } - - selectedRole := opts.role - - if selectedRole == "" { - prompt := &survey.Select{ - Message: "Role", - Options: permissionSetOptions, - } - err = survey.AskOne(prompt, &selectedRole) - if err != nil { - return err - } - } - - permissionSetArn, ok := permissionSetValues[selectedRole] - if !ok { - return clierr.New(fmt.Sprintf("role %s not found", selectedAccountID), clierr.Infof("run 'granted exp request aws --account %s' to see a list of available roles", selectedAccountID)) - } - - selectedPermissionSetRuleIDs := permissionSetRuleIDs[selectedRole] - - // find Access Rules that match the permission set and the account - // we need to find the intersection between permissionSetRuleIDs and accessRulesForAccount - // matchingAccessRule tracks the current Access Rule which we'll use to request access against. - var matchingAccessRule *cache.AccessRule - - for ruleID := range ruleIDs { - if _, ok := selectedPermissionSetRuleIDs[ruleID]; ok { - - // the Access Rule matches both the account and the permission set and could be selected - rule := existingRules[ruleID] - - clio.Debugw("considering access rule", "rule.proposed", rule, "rule.matched", matchingAccessRule) - - // if we haven't found a match yet, set the matching access rule as this one. - if matchingAccessRule == nil { - matchingAccessRule = &rule - continue - } - - // if we've found a match, use this rule if it's lesser "resistance" than the existing - // matched one. - - // the proposed rule will take priority if it doesn't require approval - if matchingAccessRule.RequiresApproval && !rule.RequiresApproval { - matchingAccessRule = &rule - continue - } - - // the proposed rule will take priority if it has a longer duration - if matchingAccessRule.RequiresApproval == rule.RequiresApproval && - matchingAccessRule.DurationSeconds < rule.DurationSeconds { - matchingAccessRule = &rule - continue - } - } - } - - clio.Debugw("matched access rule", "rule.matched", matchingAccessRule) - - reason := opts.reason - - fr, err := frecency.Load("reasons") - if err != nil { - return err - } - - if reason == "" { - var suggestions []string - for _, entry := range fr.Entries { - e := entry.Entry.(string) - suggestions = append(suggestions, e) - } - - reasonPrompt := &survey.Input{ - Message: "Reason for access:", - Help: "Will be stored in audit trails and associated with you", - Suggest: func(toComplete string) []string { - var matched []string - for _, s := range suggestions { - if fuzzy.Match(toComplete, s) { - matched = append(matched, s) - } - } - - return matched - }, - } - err = survey.AskOne(reasonPrompt, &reason) - if err != nil { - return err - } - } - - err = fr.Upsert(reason) - if err != nil { - clio.Errorw("error updating frecency log", "error", err) - } - - // only print the one-liner if --reason wasn't provided - if opts.reason == "" { - clio.NewLine() - clio.Infof("Run this one-liner command to request access in future:\ngranted exp request aws --account %s --role %s --reason \"%s\"", selectedAccountID, selectedRole, reason) - clio.NewLine() - } - - si := spinner.New(spinner.CharSets[14], 100*time.Millisecond) - si.Suffix = " requesting access..." - si.Writer = os.Stderr - si.Start() - - // the current version of the API requires `With` fields to be provided - // *only* if the Access Rule has multiple options for that field. - var with []types.CreateRequestWith - request := types.CreateRequestWith{ - AdditionalProperties: make(map[string][]string), - } - - var accountIdCount, permissionSetCount int - - for _, t := range matchingAccessRule.Targets { - if t.Type == "accountId" { - accountIdCount++ - } - if t.Type == "permissionSetArn" { - permissionSetCount++ - } - } - - // check if the 'accountId' field needs to be included - if accountIdCount > 1 { - request.AdditionalProperties["accountId"] = []string{selectedAccountID} - } - - // check if the 'permissionSetArn' field needs to be included - if permissionSetCount > 1 { - request.AdditionalProperties["permissionSetArn"] = []string{permissionSetArn} - } - - // withPtr is set to null if the `With` field doesn't contain anything. - // it is used to avoid API bad request errors. - var withPtr *[]types.CreateRequestWith - if len(request.AdditionalProperties) > 0 { - with = append(with, request) - withPtr = &with - } - - requestDuration := matchingAccessRule.DurationSeconds - if opts.duratiuon != 0 && int(opts.duratiuon.Seconds()) < requestDuration { - requestDuration = int(opts.duratiuon.Seconds()) - } else if int(opts.duratiuon.Seconds()) > requestDuration { - clio.Warn("The maximum time set for this access request is ", durafmt.Parse(time.Duration(requestDuration)*time.Second).LimitFirstN(1).String()) - } - - _, err = cf.UserCreateRequestWithResponse(ctx, types.UserCreateRequestJSONRequestBody{ - AccessRuleId: matchingAccessRule.ID, - Reason: &reason, - Timing: types.RequestTiming{ - DurationSeconds: requestDuration, - }, - With: withPtr, - }) - - if err != nil { - if strings.Contains(err.Error(), "this request overlaps an existing grant") { - clio.Warn("This request has already been approved, continuing anyway...") - } else { - return err - } - } - - si.Stop() - - // Call granted sso populate here - - startURL := gConf.CommonFateDefaultSSOStartURL - - region := gConf.CommonFateDefaultSSORegion - - configFilename := cfaws.GetAWSConfigPath() - - config, err := ini.LoadSources(ini.LoadOptions{ - AllowNonUniqueSections: false, - SkipUnrecognizableLines: false, - AllowNestedValues: true, - }, configFilename) - if err != nil { - if !os.IsNotExist(err) { - return err - } - config = ini.Empty() - } - - pruneStartURLs := []string{startURL} - - g := awsconfigfile.Generator{ - Config: config, - ProfileNameTemplate: awsconfigfile.DefaultProfileNameTemplate, - NoCredentialProcess: false, - Prefix: "", - PruneStartURLs: pruneStartURLs, - } - - ps := profilesource.Source{SSORegion: region, StartURL: startURL, Client: cf, DashboardURL: cfcfg.CurrentOrEmpty().DashboardURL} - - g.AddSource(ps) - clio.Info("Updating your AWS config file (~/.aws/config) with profiles from Common Fate...") - err = g.Generate(ctx) - if err != nil { - return err - } - - err = config.SaveTo(configFilename) - if err != nil { - return err - } - - // find the latest Access Request - res, err := cf.UserListRequestsWithResponse(ctx, &types.UserListRequestsParams{}) - if err != nil { - return err - } - - latestRequest := res.JSON200.Requests[0] - - reqURL, err := url.Parse(cfcfg.CurrentOrEmpty().DashboardURL) - if err != nil { - return err - } - reqURL.Path = path.Join("/requests", latestRequest.ID) - - // Access Request: Approved (https://commonfate.example.com/requests/req_12345) - clio.Infof("Access Request: %s (%s)", cases.Title(language.English).String(strings.ToLower(string(latestRequest.Status))), reqURL) - - fullName := fmt.Sprintf("%s/%s", selectedAccountInfo.Label, selectedRole) - fullName = strings.ReplaceAll(fullName, " ", "-") // Replacing spaces with "-" to make export AWS_PROFILE work properly - - if latestRequest.Status == types.RequestStatusAPPROVED { - durationDescription := durafmt.Parse(time.Duration(requestDuration) * time.Second).LimitFirstN(1).String() - profile, err := cfaws.LoadProfileByAccountIdAndRole(selectedAccountID, selectedRole) - if err != nil { - // make sure to print err.Error(), rather than just err. - // If the argument to Errorw is an error rather than a string, zap will print the stack trace from where the error originated. - // This makes the log output look quite messy. - clio.Errorw("error while trying to automatically detect if profile is active", "error", err.Error()) - clio.Infof("To use the profile with the AWS CLI, sync your ~/.aws/config by running 'granted sso populate'. Then, run:\nexport AWS_PROFILE=%s", fullName) - return nil - } - - if profile == nil { - clio.Errorw("unable to automatically await access because profile was not found") - clio.Infof("To use the profile with the AWS CLI, sync your ~/.aws/config by running 'granted sso populate'. Then, run:\nexport AWS_PROFILE=%s", fullName) - return nil - } - ssoAssumer := cfaws.AwsSsoAssumer{} - profile.ProfileType = ssoAssumer.Type() - - clio.Debugf("attempting to assume the profile: %s to see that it is ready for use.", profile.Name) - si := spinner.New(spinner.CharSets[14], 100*time.Millisecond) - si.Suffix = " waiting for the profile to be ready..." - si.Writer = os.Stderr - si.Start() - - // run assume with retry such that even if assume fails due to latency issue in provisioning, user will not have to rerun the command. - _, err = profile.AssumeTerminal(ctx, cfaws.ConfigOpts{ - ShouldRetryAssuming: aws.Bool(true), - }) - if err != nil { - // make sure to print err.Error(), rather than just err. - // If the argument to Errorw is an error rather than a string, zap will print the stack trace from where the error originated. - // This makes the log output look quite messy. - clio.Errorw("error while trying to automatically detect if profile is active by assuming the role", "error", err.Error()) - clio.Infof("To use the profile with the AWS CLI, sync your ~/.aws/config by running 'granted sso populate'. Then, run:\nexport AWS_PROFILE=%s", fullName) - return nil - } - si.Stop() - - clio.Successf("[%s] Access is activated (expires in %s)", fullName, durationDescription) - clio.NewLine() - clio.Infof("To use the profile with the AWS CLI, run:\nexport AWS_PROFILE=%s", fullName) - return nil - } - clio.NewLine() - clio.Infof("Your request is not yet approved, to use the profile with the AWS CLI once it is approved, sync your ~/.aws/config by running 'granted sso populate'. Then, run:\nexport AWS_PROFILE=%s", fullName) - - return nil -} - -func RefreshCachedAccessRules(ctx context.Context, depID string, cf *types.ClientWithResponses) (accounts map[string]cache.AccessTarget, existingRules map[string]cache.AccessRule, accessRulesForAccount map[string]map[string]bool, err error) { - //try refreshing the cache and repulling accounts - // note: we use a map here to de-duplicate accounts. - // this means that the RuleID in the accounts map is not necessarily - // the *only* Access Rule which grants access to that account. - accounts = map[string]cache.AccessTarget{} - - existingRules, err = getCachedAccessRules(depID) - if err != nil { - return nil, nil, nil, err - } - - rules, err := cf.UserListAccessRulesWithResponse(ctx) - if err != nil { - return nil, nil, nil, err - - } - - for _, r := range rules.JSON200.AccessRules { - var g errgroup.Group - - g.Go(func() error { - return updateCachedAccessRule(ctx, updateCacheOpts{ - Rule: r, - Existing: existingRules, - DeploymentID: depID, - CF: cf, - }) - }) - - err = g.Wait() - if err != nil { - return nil, nil, nil, err - } - - } - - // refresh the cache - newexistingRules, err := getCachedAccessRules(depID) - if err != nil { - return nil, nil, nil, err - } - accessRulesForAccount = map[string]map[string]bool{} - - for _, rule := range newexistingRules { - for _, t := range rule.Targets { - if t.Type == "accountId" { - if _, ok := accessRulesForAccount[t.Value]; !ok { - accessRulesForAccount[t.Value] = map[string]bool{} - } - accounts[t.Value] = t - accessRulesForAccount[t.Value][rule.ID] = true - } - } - } - - return accounts, existingRules, accessRulesForAccount, nil -} - -func getCachedAccessRules(depID string) (map[string]cache.AccessRule, error) { - cacheFolder, err := getCacheFolder(depID) - if err != nil { - return nil, err - } - - files, err := os.ReadDir(cacheFolder) - if err != nil { - return nil, errors.Wrap(err, "reading cache folder") - } - - // map of rule ID to the rule itself - rules := map[string]cache.AccessRule{} - - for _, f := range files { - // the name of the file is the rule ID (e.g. `rul_123`) - ruleBytes, err := os.ReadFile(path.Join(cacheFolder, f.Name())) - if err != nil { - return nil, err - } - var rule cache.AccessRule - err = json.Unmarshal(ruleBytes, &rule) - if err != nil { - return nil, err - } - - rules[f.Name()] = rule - } - - return rules, nil -} - -func clearCachedAccessRules(depID string) error { - cacheFolder, err := getCacheFolder(depID) - if err != nil { - return err - } - - return os.RemoveAll(cacheFolder) -} - -type updateCacheOpts struct { - Rule types.AccessRule - Existing map[string]cache.AccessRule - DeploymentID string - CF *client.Client -} - -func updateCachedAccessRule(ctx context.Context, opts updateCacheOpts) error { - r := opts.Rule - if opts.Rule.Target.Provider.Type != "aws-sso" { - clio.Debugw("skipping syncing rule: only aws-sso provider type supported", "rule.provider.type", opts.Rule.Target.Provider.Type) - return nil - } - - existing, ok := opts.Existing[r.ID] - - if ok { - // the rule exists in the cache - check whether it's been updated - // since we last saw it. - cacheUpdatedAt := time.Unix(existing.UpdatedAt, 0) - if !opts.Rule.UpdatedAt.After(opts.Rule.UpdatedAt) { - clio.Debugw("rule is up to date: skipping sync", "rule.id", r.ID, "cache.updated_at", cacheUpdatedAt.Unix(), "rule.updated_at", opts.Rule.UpdatedAt.Unix()) - return nil - } - clio.Debugw("rule is out of date", "rule.id", r.ID, "cache.updated_at", cacheUpdatedAt.Unix(), "rule.updated_at", opts.Rule.UpdatedAt.Unix()) - } - - // otherwise, update the cache - row := cache.AccessRule{ - ID: r.ID, - Name: r.Name, - DeploymentID: opts.DeploymentID, - TargetProviderID: r.Target.Provider.Id, - TargetProviderType: r.Target.Provider.Type, - CreatedAt: r.CreatedAt.Unix(), - UpdatedAt: r.UpdatedAt.Unix(), - DurationSeconds: r.TimeConstraints.MaxDurationSeconds, - } - - // our API doesn't easily expose whether manual approval is required - // on an Access Rule, so we need to fetch approvers separately. - approvers, err := opts.CF.UserGetAccessRuleApproversWithResponse(ctx, r.ID) - if err != nil { - return err - } - - if len(approvers.JSON200.Users) > 0 { - row.RequiresApproval = true - } - - clio.Debugw("updated requires approval", "rule.id", r.ID, "requires_approval", row.RequiresApproval) - - details, err := opts.CF.UserGetAccessRuleWithResponse(ctx, r.ID) - if err != nil { - return err - } - - for k, v := range details.JSON200.Target.Arguments.AdditionalProperties { - for _, o := range v.Options { - t := cache.AccessTarget{ - RuleID: r.ID, - Type: k, - Label: o.Label, - Value: o.Value, - } - - if o.Description != nil { - t.Description = *o.Description - } - row.Targets = append(row.Targets, t) - } - } - - clio.Debugw("updated access targets", "rule.id", r.ID, "targets.count", len(row.Targets)) - - cacheFolder, err := getCacheFolder(opts.DeploymentID) - if err != nil { - return err - } - - filename := filepath.Join(cacheFolder, r.ID) - - ruleBytes, err := json.Marshal(row) - if err != nil { - return err - } - - err = os.WriteFile(filename, ruleBytes, 0644) - if err != nil { - return err - } - - return nil -} - -func getCacheFolder(depID string) (string, error) { - configFolder, err := grantedConfig.GrantedCacheFolder() - if err != nil { - return "", err - } - depURL, err := url.Parse(depID) - if err != nil { - return "", err - } - - // ~/.granted/common-fate-cache/commonfate.example.com/access-rules - cacheFolder := path.Join(configFolder, "common-fate-cache", depURL.Hostname(), "access-rules") - - if _, err := os.Stat(cacheFolder); os.IsNotExist(err) { - clio.Debugw("cache folder does not exist, creating", "folder", cacheFolder, "error", err) - err = os.MkdirAll(cacheFolder, 0755) - if err != nil { - return "", errors.Wrapf(err, "creating cache folder %s", cacheFolder) - } - } - - return cacheFolder, nil -} diff --git a/pkg/granted/proxy/ensureaccess.go b/pkg/granted/proxy/ensureaccess.go deleted file mode 100644 index 646f3568..00000000 --- a/pkg/granted/proxy/ensureaccess.go +++ /dev/null @@ -1,127 +0,0 @@ -package proxy - -import ( - "context" - "errors" - "time" - - "connectrpc.com/connect" - "github.com/common-fate/clio" - "github.com/fwdcloudsec/granted/pkg/hook/accessrequesthook" - "github.com/common-fate/sdk/config" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - "github.com/common-fate/sdk/service/access/grants" - sethRetry "github.com/sethvargo/go-retry" - "google.golang.org/protobuf/types/known/durationpb" -) - -func durationOrDefault(duration time.Duration) *durationpb.Duration { - var out *durationpb.Duration - if duration != 0 { - out = durationpb.New(duration) - } - return out -} - -type EnsureAccessInput[T any] struct { - Target string - Role string - Duration time.Duration - Reason string - Attachments []string - Confirm bool - Wait bool - PromptForEntitlement func(ctx context.Context, cfg *config.Context) (*accessv1alpha1.Entitlement, error) - GetGrantOutput func(msg *accessv1alpha1.GetGrantOutputResponse) (T, error) -} -type EnsureAccessOutput[T any] struct { - GrantOutput T - Grant *accessv1alpha1.Grant -} - -// ensureAccess checks for an existing grant or creates a new one if it does not exist -func EnsureAccess[T any](ctx context.Context, cfg *config.Context, input EnsureAccessInput[T]) (*EnsureAccessOutput[T], error) { - - accessRequestInput := accessrequesthook.NoEntitlementAccessInput{ - Target: input.Target, - Role: input.Role, - Reason: input.Reason, - Attachments: input.Attachments, - Duration: durationOrDefault(input.Duration), - Confirm: input.Confirm, - Wait: input.Wait, - StartTime: time.Now(), - } - - if accessRequestInput.Target == "" && accessRequestInput.Role == "" { - selectedEntitlement, err := input.PromptForEntitlement(ctx, cfg) - if err != nil { - return nil, err - } - clio.Debugw("selected target and role manually", "selectedEntitlement", selectedEntitlement) - accessRequestInput.Target = selectedEntitlement.Target.Eid.Display() - accessRequestInput.Role = selectedEntitlement.Role.Eid.Display() - } - - hook := accessrequesthook.Hook{} - retry, result, _, err := hook.NoEntitlementAccess(ctx, cfg, accessRequestInput) - if err != nil { - return nil, err - } - - retryDuration := time.Minute * 1 - if input.Wait { - //if wait is specified, increase the timeout to 15 minutes. - retryDuration = time.Minute * 15 - } - - if retry { - // reset the start time for the timer (otherwise it shows 2s, 7s, 12s etc) - accessRequestInput.StartTime = time.Now() - - b := sethRetry.NewConstant(5 * time.Second) - b = sethRetry.WithMaxDuration(retryDuration, b) - err = sethRetry.Do(ctx, b, func(ctx context.Context) (err error) { - - //also proactively check if request has been approved and attempt to activate - result, err = hook.RetryNoEntitlementAccess(ctx, cfg, accessRequestInput) - if err != nil { - - return sethRetry.RetryableError(err) - } - - return nil - }) - if err != nil { - return nil, err - } - - } - - if result == nil || len(result.Grants) == 0 { - return nil, errors.New("could not load grant from Common Fate") - } - - grant := result.Grants[0] - - grantsClient := grants.NewFromConfig(cfg) - - grantOutput, err := grantsClient.GetGrantOutput(ctx, connect.NewRequest(&accessv1alpha1.GetGrantOutputRequest{ - Id: grant.Grant.Id, - })) - if err != nil { - return nil, err - } - - clio.Debugw("found grant output", "output", grantOutput) - - grantOutputFromRes, err := input.GetGrantOutput(grantOutput.Msg) - if err != nil { - return nil, err - } - - return &EnsureAccessOutput[T]{ - GrantOutput: grantOutputFromRes, - Grant: grant.Grant, - }, nil -} diff --git a/pkg/granted/proxy/initiateconnection.go b/pkg/granted/proxy/initiateconnection.go deleted file mode 100644 index 946d1a99..00000000 --- a/pkg/granted/proxy/initiateconnection.go +++ /dev/null @@ -1,55 +0,0 @@ -package proxy - -import ( - "fmt" - "net" - - "github.com/common-fate/clio" - "github.com/common-fate/clio/clierr" - "github.com/common-fate/sdk/config" - "github.com/common-fate/sdk/handshake" - "github.com/hashicorp/yamux" -) - -type InitiateSessionConnectionInput struct { - GrantID string - RequestURL string - LocalPort int -} - -// InitiateSessionConnection starts a new tcp connection to through the SSM port forward and completes a handshake with the proxy server -// the result is a yamux session which is used to multiplex client connections -func InitiateSessionConnection(cfg *config.Context, input InitiateSessionConnectionInput) (net.Conn, *yamux.Session, error) { - - // First dial the local SSM portforward, which will be running on a randomly chosen port - // or the local proxy server instance if it's local dev mode - // this establishes the initial connection to the Proxy server - clio.Debugw("dialing proxy server", "host", fmt.Sprintf("localhost:%d", input.LocalPort)) - rawServerConn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", input.LocalPort)) - if err != nil { - return nil, nil, clierr.New("failed to establish a connection to the remote proxy server", clierr.Error(err), clierr.Infof("Your grant may have expired, you can check the status here: %s and retry connecting", input.RequestURL)) - } - // Next, a handshake is performed between the cli client and the Proxy server - // this handshake establishes the users identity to the Proxy, and also the validity of a Database grant - handshaker := handshake.NewHandshakeClient(rawServerConn, input.GrantID, cfg.TokenSource) - handshakeResult, err := handshaker.Handshake() - if err != nil { - return nil, nil, clierr.New("failed to authenticate connection to the remote proxy server", clierr.Error(err), clierr.Infof("Your grant may have expired, you can check the status here: %s and retry connecting", input.RequestURL)) - } - clio.Debugw("handshakeResult", "result", handshakeResult) - - // When the handshake process has completed successfully, we use yamux to establish a multiplexed stream over the existing connection - // We use a multiplexed stream here so that multiple clients can be connected and have their logs attributed to the same session in our audit trail - // To the clients, this is completely opaque - multiplexedServerClient, err := yamux.Client(rawServerConn, nil) - if err != nil { - return nil, nil, err - } - - // Sanity check to confirm that the multiplexed stream is working - _, err = multiplexedServerClient.Ping() - if err != nil { - return nil, nil, fmt.Errorf("failed to healthcheck the network connection to the proxy server: %w", err) - } - return rawServerConn, multiplexedServerClient, nil -} diff --git a/pkg/granted/proxy/listenandproxy.go b/pkg/granted/proxy/listenandproxy.go deleted file mode 100644 index 454440fc..00000000 --- a/pkg/granted/proxy/listenandproxy.go +++ /dev/null @@ -1,99 +0,0 @@ -package proxy - -import ( - "context" - "fmt" - "io" - "net" - - "github.com/common-fate/clio" - "github.com/common-fate/clio/clierr" - "github.com/hashicorp/yamux" - "go.uber.org/zap" -) - -// ListenAndProxy will listen for new client connections and start a stream over the established proxy server session. -// if the proxy server terminates the session, like when a grant expires, this listener will detect it and terminate the CLI commmand with an error explaining what happened -func ListenAndProxy(ctx context.Context, yamuxStreamConnection *yamux.Session, clientConnectionPort int, requestURL string) error { - ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", clientConnectionPort)) - if err != nil { - return fmt.Errorf("failed to start listening for connections on port: %d. %w", clientConnectionPort, err) - } - defer func() { _ = ln.Close() }() - - type result struct { - conn net.Conn - err error - } - resultChan := make(chan result, 100) - go func() { - for { - select { - case <-ctx.Done(): - return - default: - conn, err := ln.Accept() - result := result{ - err: err, - } - if err == nil { - result.conn = conn - } - resultChan <- result - } - } - }() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-yamuxStreamConnection.CloseChan(): - return clierr.New("The connection to the proxy server has ended", clierr.Infof("Your grant may have expired, you can check the status here: %s and retry connecting", requestURL)) - case result := <-resultChan: - if result.err != nil { - return fmt.Errorf("failed to accept connection: %w", err) - } - if yamuxStreamConnection.IsClosed() { - return clierr.New("failed to accept connection for client because the proxy server connection has ended", clierr.Infof("Your grant may have expired, you can check the status here: %s and retry connecting", requestURL)) - } - go func(clientConn net.Conn) { - - // A stream is opened for this connection, streams are used just like a net.Conn and can read and write data - // A stream can only be opened while the grant is still valid, and each new connection will validate the parameters - sessionConn, err := yamuxStreamConnection.OpenStream() - if err != nil { - clio.Error("Failed to establish a new connection to the remote via the proxy server.") - clio.Error(err) - clio.Infof("Your grant may have expired, you can check the status here: %s", requestURL) - return - } - - clio.Infof("Connection accepted for session [%v]", sessionConn.StreamID()) - - // If a stream successfully connects, that means that a connection to the target is now open - // at this point the connection traffic is handed off and the connection is effectively directly from the client and the target - // with queries being intercepted and logged to the audit trail in Common Fate - // if the grant becomes incative at any time the connection is terminated immediately - go func() { - defer func() { _ = clientConn.Close() }() - defer func() { _ = sessionConn.Close() }() - _, err := io.Copy(sessionConn, clientConn) - if err != nil { - clio.Debugw("error writing data from client to server usually this is just because the proxy session ended.", "streamId", sessionConn.StreamID(), zap.Error(err)) - } - clio.Infof("Connection ended for session [%v]", sessionConn.StreamID()) - }() - go func() { - defer func() { _ = clientConn.Close() }() - defer func() { _ = sessionConn.Close() }() - _, err := io.Copy(clientConn, sessionConn) - if err != nil { - clio.Debugw("error writing data from server to client usually this is just because the proxy session ended.", "streamId", sessionConn.StreamID(), zap.Error(err)) - } - - }() - }(result.conn) - } - } -} diff --git a/pkg/granted/proxy/ports.go b/pkg/granted/proxy/ports.go deleted file mode 100644 index fd6c0534..00000000 --- a/pkg/granted/proxy/ports.go +++ /dev/null @@ -1,37 +0,0 @@ -package proxy - -import ( - "net" -) - -// Returns the proxy port to connect to and a local port to send client connections to -// in production, an SSM portforward process is running which is used to connect to the proxy server -// and over the top of this connection, a handshake process takes place and connection multiplexing is used to handle multiple database clients -func Ports(isLocalMode bool) (serverPort, localPort int, err error) { - // in local mode the SSM port forward is not used can skip using ssm and just use a local port forward instead - if isLocalMode { - return 7070, 7070, nil - } - // find an unused local port to use for the ssm server - // the user doesn't directly connect to this, they connect through our local proxy - // which adds authentication - ssmPortforwardLocalPort, err := GrabUnusedPort() - if err != nil { - return 0, 0, err - } - return 8080, ssmPortforwardLocalPort, nil -} - -func GrabUnusedPort() (int, error) { - listener, err := net.Listen("tcp", ":0") - if err != nil { - return 0, err - } - - port := listener.Addr().(*net.TCPAddr).Port - err = listener.Close() - if err != nil { - return 0, err - } - return port, nil -} diff --git a/pkg/granted/proxy/prompt.go b/pkg/granted/proxy/prompt.go deleted file mode 100644 index 9a4f6493..00000000 --- a/pkg/granted/proxy/prompt.go +++ /dev/null @@ -1,83 +0,0 @@ -package proxy - -import ( - "fmt" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/charmbracelet/lipgloss" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - "github.com/mattn/go-runewidth" -) - -func filterMultiToken(filterValue string, optValue string, optIndex int) bool { - optValue = strings.ToLower(optValue) - filters := strings.Split(strings.ToLower(filterValue), " ") - for _, filter := range filters { - if !strings.Contains(optValue, filter) { - return false - } - } - return true -} -func PromptEntitlements(entitlements []*accessv1alpha1.Entitlement, targetHeader string, roleHeader string, promptMessage string) (*accessv1alpha1.Entitlement, error) { - type Column struct { - Title string - Width int - } - cols := []Column{{Title: targetHeader, Width: 40}, {Title: roleHeader, Width: 40}} - var s = make([]string, 0, len(cols)) - for _, col := range cols { - style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) - renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) - s = append(s, lipgloss.NewStyle().Bold(true).Padding(0).Render(renderedCell)) - } - header := lipgloss.NewStyle().PaddingLeft(2).Render(lipgloss.JoinHorizontal(lipgloss.Left, s...)) - var options []string - optionsMap := make(map[string]*accessv1alpha1.Entitlement) - for i, entitlement := range entitlements { - style := lipgloss.NewStyle().Width(cols[0].Width).MaxWidth(cols[0].Width).Inline(true) - target := lipgloss.NewStyle().Bold(true).Padding(0).Render(style.Render(runewidth.Truncate(entitlement.Target.Display(), cols[0].Width, "…"))) - - style = lipgloss.NewStyle().Width(cols[1].Width).MaxWidth(cols[1].Width).Inline(true) - role := lipgloss.NewStyle().Bold(true).Padding(0).Render(style.Render(runewidth.Truncate(entitlement.Role.Display(), cols[1].Width, "…"))) - - option := lipgloss.JoinHorizontal(lipgloss.Left, target, role) - options = append(options, option) - optionsMap[option] = entitlements[i] - } - - originalSelectTemplate := survey.SelectQuestionTemplate - survey.SelectQuestionTemplate = fmt.Sprintf(` -{{- define "option"}} - {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}} - {{- .CurrentOpt.Value}}{{ if ne ($.GetDescription .CurrentOpt) "" }} - {{color "cyan"}}{{ $.GetDescription .CurrentOpt }}{{end}} - {{- color "reset"}} -{{end}} -{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} -{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} -{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} -{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}} -{{- else}} - {{- " "}}{{- color "cyan"}}[Use arrows to move, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}} - {{- "\n"}} -%s{{- "\n"}} - {{- range $ix, $option := .PageEntries}} - {{- template "option" $.IterateOption $ix $option}} - {{- end}} -{{- end}}`, header) - - var out string - err := survey.AskOne(&survey.Select{ - Message: promptMessage, - Options: options, - Filter: filterMultiToken, - }, &out) - if err != nil { - return nil, err - } - - survey.SelectQuestionTemplate = originalSelectTemplate - - return optionsMap[out], nil -} diff --git a/pkg/granted/proxy/proxy.go b/pkg/granted/proxy/proxy.go deleted file mode 100644 index 40bb8fd6..00000000 --- a/pkg/granted/proxy/proxy.go +++ /dev/null @@ -1,160 +0,0 @@ -package proxy - -import ( - "context" - "fmt" - "io" - "os" - "strconv" - "time" - - awsConfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/ssm" - "github.com/aws/session-manager-plugin/src/datachannel" - "github.com/aws/session-manager-plugin/src/sessionmanagerplugin/session" - "github.com/aws/session-manager-plugin/src/sessionmanagerplugin/session/portsession" - "github.com/briandowns/spinner" - "github.com/common-fate/clio" - "github.com/common-fate/clio/clierr" - "github.com/common-fate/grab" - "github.com/fwdcloudsec/granted/internal/build" - "github.com/fwdcloudsec/granted/pkg/cfaws" - - "github.com/common-fate/xid" -) - -type DisplayOpts struct { - //the e.g `aws rds proxy` which is used to fill in a help prompt - Command string - // like `EKS Proxy` or `RDS proxy` - SessionType string -} -type AWSConfig struct { - SSOAccountID string - SSORoleName string - SSORegion string - SSOStartURL string - Region string - SSMSessionTarget string - NoCache bool -} -type ConnectionOpts struct { - ServerPort int - LocalPort int -} -type WaitForSSMConnectionToProxyServerOpts struct { - AWSConfig AWSConfig - DisplayOpts DisplayOpts - ConnectionOpts ConnectionOpts - GrantID string - RequestID string -} - -// WaitForSSMConnectionToProxyServer starts a session with SSM and waits for the connection to be ready -func WaitForSSMConnectionToProxyServer(ctx context.Context, opts WaitForSSMConnectionToProxyServerOpts) error { - - p := &cfaws.Profile{ - Name: opts.GrantID, - ProfileType: "AWS_SSO", - AWSConfig: awsConfig.SharedConfig{ - SSOAccountID: opts.AWSConfig.SSOAccountID, - SSORoleName: opts.AWSConfig.SSORoleName, - SSORegion: opts.AWSConfig.SSORegion, - SSOStartURL: opts.AWSConfig.SSOStartURL, - }, - Initialised: true, - } - - creds, err := p.AssumeTerminal(ctx, cfaws.ConfigOpts{ - ShouldRetryAssuming: grab.Ptr(true), - DisableCache: opts.AWSConfig.NoCache, - }) - if err != nil { - return err - } - - ssmReadyForConnectionsChan := make(chan struct{}) - - awscfg, err := awsConfig.LoadDefaultConfig(ctx, awsConfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken))) - if err != nil { - return err - } - awscfg.Region = opts.AWSConfig.Region - ssmClient := ssm.NewFromConfig(awscfg) - - var sessionOutput *ssm.StartSessionOutput - - documentName := "AWS-StartPortForwardingSession" - startSessionInput := ssm.StartSessionInput{ - Target: &opts.AWSConfig.SSMSessionTarget, - DocumentName: &documentName, - Parameters: map[string][]string{ - "portNumber": {strconv.Itoa(opts.ConnectionOpts.ServerPort)}, - "localPortNumber": {strconv.Itoa(opts.ConnectionOpts.LocalPort)}, - }, - Reason: grab.Ptr(fmt.Sprintf("Session started for Granted %s connection with Common Fate. GrantID: %s, AccessRequestID: %s", opts.DisplayOpts.SessionType, opts.GrantID, opts.RequestID)), - } - - sessionOutput, err = ssmClient.StartSession(ctx, &startSessionInput) - if err != nil { - return clierr.New("Failed to start AWS SSM port forward session", - clierr.Error(err), - clierr.Infof("You can try re-running this command with the verbose flag to see detailed logs, '%s --verbose %s'", build.GrantedBinaryName(), opts.DisplayOpts.Command), - clierr.Infof("In rare cases, where the proxy service has been re-deployed while your grant was active, you will need to close your request in Common Fate and request access again 'cf access close request --id=%s' This is usually indicated by an error message containing '(TargetNotConnected) when calling the StartSession'", opts.RequestID)) - } - - clientId := xid.New("gtd") - ssmSession := session.Session{ - StreamUrl: *sessionOutput.StreamUrl, - SessionId: *sessionOutput.SessionId, - TokenValue: *sessionOutput.TokenValue, - IsAwsCliUpgradeNeeded: false, - Endpoint: fmt.Sprintf("localhost:%d", opts.ConnectionOpts.LocalPort), - DataChannel: &datachannel.DataChannel{}, - ClientId: clientId, - } - - startingProxySpinner := spinner.New(spinner.CharSets[14], 100*time.Millisecond) - startingProxySpinner.Suffix = fmt.Sprintf(" Starting %s...", opts.DisplayOpts.SessionType) - startingProxySpinner.Writer = os.Stderr - startingProxySpinner.Start() - defer startingProxySpinner.Stop() - - // registers the PortSession feature within the ssm library - _ = portsession.PortSession{} - - // the SSMDebugLogger serves two purposes here - // 1. writes ssm session logs to clio.Debug which can be viewed using the --verbose flag - // 2. scans the output for the string "Waiting for connections..." which indicates that the SSM connection was successful - // The notifier will notify the ssmReadyForConnectionsChan which means we can connect to the proxy to complete the initial handshake - ssmLogger := &SSMDebugLogger{ - Writers: []io.Writer{ - &NotifyOnSubstringMatchWriter{ - Phrase: "Waiting for connections...", - Callback: func() { ssmReadyForConnectionsChan <- struct{}{} }, - }, - DebugWriter{}, - }, - } - - // Connect to the Proxy server using SSM - go func() { - // Execute starts the ssm connection - err = ssmSession.Execute(ssmLogger) - if err != nil { - clio.Error("AWS SSM port forward session closed with an error") - clio.Error(err) - clio.Info("You can try re-running this command with the verbose flag to see detailed logs, '%s --verbose %s'", build.GrantedBinaryName(), opts.DisplayOpts.Command) - clio.Infof("In rare cases, where the proxy service has been re-deployed while your grant was active, you will need to close your request in Common Fate and request access again 'cf access close request --id=%s' This is usually indicated by an error message containing '(TargetNotConnected) when calling the StartSession'", opts.RequestID) - } - }() - - // waits for the ssm session to start or context to be cancelled - select { - case <-ssmReadyForConnectionsChan: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} diff --git a/pkg/granted/proxy/ssm_logger.go b/pkg/granted/proxy/ssm_logger.go deleted file mode 100644 index fb23dcb1..00000000 --- a/pkg/granted/proxy/ssm_logger.go +++ /dev/null @@ -1,101 +0,0 @@ -package proxy - -import ( - "fmt" - "io" - - "github.com/aws/session-manager-plugin/src/log" -) - -type SSMDebugLogger struct { - // Writers to write logging output to - Writers []io.Writer -} - -func (l *SSMDebugLogger) WithContext(context ...string) (contextLogger log.T) { - msg := fmt.Sprint(context) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } - return l -} -func (l *SSMDebugLogger) Close() {} -func (l *SSMDebugLogger) Critical(v ...interface{}) error { - msg := fmt.Sprint(v...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } - return nil -} -func (l *SSMDebugLogger) Criticalf(format string, params ...interface{}) error { - msg := fmt.Sprintf(format, params...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } - return nil -} -func (l *SSMDebugLogger) Debug(v ...interface{}) { - msg := fmt.Sprint(v...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } -} -func (l *SSMDebugLogger) Debugf(format string, params ...interface{}) { - msg := fmt.Sprintf(format, params...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } -} -func (l *SSMDebugLogger) Error(v ...interface{}) error { - msg := fmt.Sprint(v...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } - return nil -} -func (l *SSMDebugLogger) Errorf(format string, params ...interface{}) error { - msg := fmt.Sprintf(format, params...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } - return nil -} -func (l *SSMDebugLogger) Flush() {} -func (l *SSMDebugLogger) Info(v ...interface{}) { - msg := fmt.Sprint(v...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } -} -func (l *SSMDebugLogger) Infof(format string, params ...interface{}) { - msg := fmt.Sprintf(format, params...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } -} -func (l *SSMDebugLogger) Trace(v ...interface{}) { - msg := fmt.Sprint(v...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } -} -func (l *SSMDebugLogger) Tracef(format string, params ...interface{}) { - msg := fmt.Sprintf(format, params...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } -} -func (l *SSMDebugLogger) Warn(v ...interface{}) error { - msg := fmt.Sprint(v...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } - return nil -} -func (l *SSMDebugLogger) Warnf(format string, params ...interface{}) error { - msg := fmt.Sprintf(format, params...) - for _, writer := range l.Writers { - _, _ = writer.Write([]byte(msg)) - } - return nil -} diff --git a/pkg/granted/proxy/writers.go b/pkg/granted/proxy/writers.go deleted file mode 100644 index b41e6136..00000000 --- a/pkg/granted/proxy/writers.go +++ /dev/null @@ -1,30 +0,0 @@ -package proxy - -import ( - "strings" - - "github.com/common-fate/clio" -) - -// DebugWriter is an io.Writer that writes messages using clio.Debug. -type DebugWriter struct{} - -// Write implements the io.Writer interface for DebugWriter. -func (dw DebugWriter) Write(p []byte) (n int, err error) { - message := string(p) - clio.Debug(message) - return len(p), nil -} - -type NotifyOnSubstringMatchWriter struct { - Phrase string - Callback func() -} - -func (nw *NotifyOnSubstringMatchWriter) Write(p []byte) (n int, err error) { - // Check if the phrase is in the input - if strings.Contains(string(p), nw.Phrase) { - go nw.Callback() - } - return len(p), nil -} diff --git a/pkg/granted/rds/local_port.go b/pkg/granted/rds/local_port.go deleted file mode 100644 index f985d40d..00000000 --- a/pkg/granted/rds/local_port.go +++ /dev/null @@ -1,31 +0,0 @@ -package rds - -type getLocalPortInput struct { - // OverrideFlag is set by the user using the --port flag - OverrideFlag int - // DefaultFromServer is the port number specified by admins in the Terraform provider - DefaultFromServer int - // Fallback is the port to default to if OverrideFlag and DefaultFromServer are not set - Fallback int -} - -// getLocalPort returns the port number to use for the local port -// -// Common Fate allows admins to set default ports in the Terraform provider and -// users to override them with the --port flag when running granted rds proxy --port -// -// The order of priorities is: -// 1. OverrideFlag -// 2. DefaultFromServer -// 3. Fallback -// -// You should set Fallback to 5432 for PostgreSQL and 3306 for MySQL -func getLocalPort(input getLocalPortInput) int { - if input.OverrideFlag != 0 { - return input.OverrideFlag - } - if input.DefaultFromServer != 0 { - return input.DefaultFromServer - } - return input.Fallback -} diff --git a/pkg/granted/rds/local_port_test.go b/pkg/granted/rds/local_port_test.go deleted file mode 100644 index 85136073..00000000 --- a/pkg/granted/rds/local_port_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package rds - -import "testing" - -func Test_getLocalPort(t *testing.T) { - type args struct { - input getLocalPortInput - } - tests := []struct { - name string - args args - want int - }{ - // TODO: Add test cases. - { - name: "OverridePortTakesPriority", - args: args{ - input: getLocalPortInput{ - OverrideFlag: 5000, - DefaultFromServer: 8080, - Fallback: 5432, - }, - }, - want: 5000, - }, - { - name: "DefaultFromServerTakesPriority", - args: args{ - input: getLocalPortInput{ - OverrideFlag: 0, - DefaultFromServer: 8080, - Fallback: 5432, - }, - }, - want: 8080, - }, - { - name: "FallbackTakesPriority", - args: args{ - input: getLocalPortInput{ - OverrideFlag: 0, - DefaultFromServer: 0, - Fallback: 5432, - }, - }, - want: 5432, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := getLocalPort(tt.args.input); got != tt.want { - t.Errorf("getLocalPort() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/pkg/granted/rds/rds.go b/pkg/granted/rds/rds.go deleted file mode 100644 index cbf9df6f..00000000 --- a/pkg/granted/rds/rds.go +++ /dev/null @@ -1,207 +0,0 @@ -package rds - -import ( - "context" - "errors" - "fmt" - - "connectrpc.com/connect" - - "github.com/common-fate/clio" - "github.com/common-fate/grab" - "github.com/fwdcloudsec/granted/pkg/cfcfg" - "github.com/fwdcloudsec/granted/pkg/granted/proxy" - "github.com/common-fate/sdk/config" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - "github.com/common-fate/sdk/service/access" - "github.com/fatih/color" - - "github.com/urfave/cli/v2" -) - -var Command = cli.Command{ - Name: "rds", - Usage: "Granted RDS plugin", - Description: "Granted RDS plugin", - Subcommands: []*cli.Command{&proxyCommand}, -} - -// isLocalMode is used where some behaviour needs to be changed to run against a local development proxy server -func isLocalMode(c *cli.Context) bool { - return c.String("mode") == "local" -} - -var proxyCommand = cli.Command{ - Name: "proxy", - Usage: "The Proxy plugin is used in conjunction with a Commnon Fate deployment to request temporary access to an AWS RDS Database", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "target", Aliases: []string{"database"}}, - &cli.StringFlag{Name: "role", Aliases: []string{"user"}}, - &cli.IntFlag{Name: "port", Usage: "The local port to forward the database connection to"}, - &cli.StringFlag{Name: "reason", Usage: "Provide a reason for requesting access to the role"}, - &cli.StringSliceFlag{Name: "attach", Usage: "Attach justifications to your request, such as a Jira ticket id or url `--attach=TP-123`"}, - &cli.BoolFlag{Name: "confirm", Aliases: []string{"y"}, Usage: "Skip confirmation prompts for access requests"}, - &cli.BoolFlag{Name: "wait", Value: true, Usage: "Wait for the access request to be approved."}, - &cli.BoolFlag{Name: "no-cache", Usage: "Disables caching of session credentials and forces a refresh", EnvVars: []string{"GRANTED_NO_CACHE"}}, - &cli.DurationFlag{Name: "duration", Aliases: []string{"d"}, Usage: "The duration for your access request"}, - &cli.StringFlag{Name: "mode", Hidden: true, Usage: "What mode to run the proxy command in, [remote,local], local is used in development to connect to a local instance of the proxy server rather than remote via SSM", Value: "remote"}, - }, - Action: func(c *cli.Context) error { - ctx := c.Context - cfg, err := config.LoadDefault(ctx) - if err != nil { - return err - } - - err = cfg.Initialize(ctx, config.InitializeOpts{}) - if err != nil { - return err - } - - ensuredAccess, err := proxy.EnsureAccess(ctx, cfg, proxy.EnsureAccessInput[*accessv1alpha1.AWSRDSOutput]{ - Target: c.String("target"), - Role: c.String("role"), - Duration: c.Duration("duration"), - Reason: c.String("reason"), - Attachments: c.StringSlice("attach"), - Confirm: c.Bool("confirm"), - Wait: c.Bool("wait"), - PromptForEntitlement: promptForDatabaseAndUser, - GetGrantOutput: func(msg *accessv1alpha1.GetGrantOutputResponse) (*accessv1alpha1.AWSRDSOutput, error) { - output := msg.GetOutputAwsRds() - if output == nil { - return nil, errors.New("unexpected grant output, this indicates an error in the Common Fate Provisioning process, you should contect your Common Fate administrator") - } - return output, nil - }, - }) - if err != nil { - return err - } - - requestURL, err := cfcfg.GenerateRequestURL(cfg.APIURL, ensuredAccess.Grant.AccessRequestId) - if err != nil { - return err - } - - serverPort, localPort, err := proxy.Ports(isLocalMode(c)) - if err != nil { - return err - } - - clio.Debugw("prepared ports for access", "serverPort", serverPort, "localPort", localPort) - if !isLocalMode(c) { - err = proxy.WaitForSSMConnectionToProxyServer(ctx, proxy.WaitForSSMConnectionToProxyServerOpts{ - AWSConfig: proxy.AWSConfig{ - SSOAccountID: ensuredAccess.GrantOutput.RdsDatabase.AccountId, - SSORoleName: ensuredAccess.GrantOutput.SsoRoleName, - SSORegion: ensuredAccess.GrantOutput.SsoRegion, - SSOStartURL: ensuredAccess.GrantOutput.SsoStartUrl, - Region: ensuredAccess.GrantOutput.RdsDatabase.Region, - SSMSessionTarget: ensuredAccess.GrantOutput.SsmSessionTarget, - NoCache: c.Bool("no-cache"), - }, - DisplayOpts: proxy.DisplayOpts{ - Command: "aws rds proxy", - SessionType: "RDS Proxy", - }, - ConnectionOpts: proxy.ConnectionOpts{ - ServerPort: serverPort, - LocalPort: localPort, - }, - GrantID: ensuredAccess.Grant.Id, - RequestID: ensuredAccess.Grant.AccessRequestId, - }) - if err != nil { - return err - } - } - - underlyingProxyServerConn, yamuxStreamConnection, err := proxy.InitiateSessionConnection(cfg, proxy.InitiateSessionConnectionInput{ - GrantID: ensuredAccess.Grant.Id, - RequestURL: requestURL, - LocalPort: localPort, - }) - if err != nil { - return err - } - defer func() { _ = underlyingProxyServerConn.Close() }() - defer func() { _ = yamuxStreamConnection.Close() }() - - connectionString, cliString, clientConnectionPort, err := clientConnectionParameters(c, ensuredAccess) - if err != nil { - return err - } - - printConnectionParameters(connectionString, cliString, ensuredAccess.GrantOutput.RdsDatabase.Engine, clientConnectionPort) - - return proxy.ListenAndProxy(ctx, yamuxStreamConnection, clientConnectionPort, requestURL) - }, -} - -// promptForDatabaseAndUser lists all available database entitlements for the user and displays a table selector UI -func promptForDatabaseAndUser(ctx context.Context, cfg *config.Context) (*accessv1alpha1.Entitlement, error) { - accessClient := access.NewFromConfig(cfg) - entitlements, err := grab.AllPages(ctx, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Entitlement, *string, error) { - res, err := accessClient.QueryEntitlements(ctx, connect.NewRequest(&accessv1alpha1.QueryEntitlementsRequest{ - PageToken: grab.Value(nextToken), - TargetType: grab.Ptr("AWS::RDS::Database"), - })) - if err != nil { - return nil, nil, err - } - return res.Msg.Entitlements, &res.Msg.NextPageToken, nil - }) - if err != nil { - return nil, err - } - - // check here to avoid nil pointer errors later - if len(entitlements) == 0 { - return nil, errors.New("you don't have access to any RDS databases") - } - - return proxy.PromptEntitlements(entitlements, "Database", "Role", "Select a database to connect to: ") - -} - -func clientConnectionParameters(c *cli.Context, ensuredAccess *proxy.EnsureAccessOutput[*accessv1alpha1.AWSRDSOutput]) (connectionString, cliString string, port int, err error) { - // Print the connection information to the user based on the database they are connecting to - // the passwords are always 'password' while the username and database will match that of the target being connected to - yellow := color.New(color.FgYellow) - switch ensuredAccess.GrantOutput.RdsDatabase.Engine { - case "postgres", "aurora-postgresql": - port = getLocalPort(getLocalPortInput{ - OverrideFlag: c.Int("port"), - DefaultFromServer: int(ensuredAccess.GrantOutput.DefaultLocalPort), - Fallback: 5432, - }) - - connectionString = yellow.Sprintf("postgresql://%s:password@127.0.0.1:%d/%s?sslmode=disable", ensuredAccess.GrantOutput.User.Username, port, ensuredAccess.GrantOutput.RdsDatabase.Database) - cliString = yellow.Sprintf(`psql "postgresql://%s:password@127.0.0.1:%d/%s?sslmode=disable"`, ensuredAccess.GrantOutput.User.Username, port, ensuredAccess.GrantOutput.RdsDatabase.Database) - case "mysql", "aurora-mysql": - port = getLocalPort(getLocalPortInput{ - OverrideFlag: c.Int("port"), - DefaultFromServer: int(ensuredAccess.GrantOutput.DefaultLocalPort), - Fallback: 3306, - }) - - connectionString = yellow.Sprintf("%s:password@tcp(127.0.0.1:%d)/%s", ensuredAccess.GrantOutput.User.Username, port, ensuredAccess.GrantOutput.RdsDatabase.Database) - cliString = yellow.Sprintf(`mysql -u %s -p'password' -h 127.0.0.1 -P %d %s`, ensuredAccess.GrantOutput.User.Username, port, ensuredAccess.GrantOutput.RdsDatabase.Database) - default: - return "", "", 0, fmt.Errorf("unsupported database engine: %s, maybe you need to update your `cf` cli", ensuredAccess.GrantOutput.RdsDatabase.Engine) - } - return -} - -func printConnectionParameters(connectionString, cliString, engine string, port int) { - clio.NewLine() - clio.Infof("Database proxy ready for connections on 127.0.0.1:%d", port) - clio.NewLine() - - clio.Infof("You can connect now using this connection string: %s", connectionString) - clio.NewLine() - - clio.Infof("Or using the %s cli: %s", engine, cliString) - clio.NewLine() -} From 64e15f60cf2ad1445841d24ddda57e2a190704e2 Mon Sep 17 00:00:00 2001 From: BillyJBryant <3013565+billyjbryant@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:21:42 -0700 Subject: [PATCH 3/7] feat: provider-agnostic AccessProvider interface + HTTP provider Extract AccessProvider interface from CommonFate-specific AccessRequestHook code. Implement HTTPProvider that speaks the generic REST/JSON provider protocol. Rename cfregistry -> httpregistry, cfcfg -> providercfg. Remove all CommonFate SDK dependencies (connectrpc, protobuf, cf-sdk, grab, glide-cli). The AccessRequestHook now works with any JIT provider that implements the provider protocol. The display logic (colored status output, spinner, reason prompts, retry flow) is preserved. Key changes: - pkg/hook/accessrequesthook/provider.go - AccessProvider interface and types - pkg/hook/httpprovider/ - REST/JSON implementation - pkg/providercfg/ - provider config discovery (replaces cfcfg) - pkg/granted/registry/httpregistry/ - HTTP registry (replaces cfregistry) - granted_access_provider_url profile key (common_fate_url as legacy alias) - pkg/granted/request/ - simplified to use AccessProvider (check/close stubbed) - pkg/granted/auth/ - simplified OIDC auth commands (login flow TODO) - pkg/granted/credential_process.go - removed CF SDK grant queries Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 34 +- go.sum | 188 -------- pkg/assume/assume.go | 40 +- pkg/cfcfg/cfcfg.go | 82 ---- pkg/granted/auth/auth.go | 46 +- pkg/granted/credential_process.go | 76 +--- pkg/granted/entrypoint.go | 18 +- pkg/granted/registry/add.go | 4 +- pkg/granted/registry/cfregistry/cfregistry.go | 156 ------- .../registry/httpregistry/httpregistry.go | 133 ++++++ pkg/granted/registry/registry.go | 5 +- pkg/granted/request/check.go | 101 ----- pkg/granted/request/close.go | 201 --------- pkg/granted/request/request.go | 70 +-- pkg/granted/settings/set.go | 6 +- pkg/granted/settings/set_test.go | 5 +- pkg/granted/sso.go | 41 +- .../accessrequesthook/accessrequesthook.go | 411 ++++++++---------- pkg/hook/accessrequesthook/provider.go | 90 ++++ pkg/hook/httpprovider/httpprovider.go | 238 ++++++++++ pkg/providercfg/providercfg.go | 94 ++++ 21 files changed, 849 insertions(+), 1190 deletions(-) delete mode 100644 pkg/cfcfg/cfcfg.go delete mode 100644 pkg/granted/registry/cfregistry/cfregistry.go create mode 100644 pkg/granted/registry/httpregistry/httpregistry.go delete mode 100644 pkg/granted/request/check.go delete mode 100644 pkg/granted/request/close.go create mode 100644 pkg/hook/accessrequesthook/provider.go create mode 100644 pkg/hook/httpprovider/httpprovider.go create mode 100644 pkg/providercfg/providercfg.go diff --git a/go.mod b/go.mod index 042e2659..62ab1b89 100644 --- a/go.mod +++ b/go.mod @@ -18,20 +18,14 @@ require ( ) require ( - connectrpc.com/connect v1.14.0 github.com/alessio/shellescape v1.4.2 github.com/briandowns/spinner v1.23.0 - github.com/common-fate/cli v1.8.0 github.com/common-fate/clio v1.2.3 - github.com/common-fate/glide-cli v0.6.0 - github.com/common-fate/grab v1.3.0 - github.com/common-fate/sdk v1.71.0 github.com/fatih/color v1.16.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-version v1.7.0 github.com/schollz/progressbar/v3 v3.13.1 go.uber.org/zap v1.26.0 - google.golang.org/protobuf v1.34.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -41,47 +35,27 @@ require ( github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect github.com/benbjohnson/clock v1.3.5 // indirect - github.com/common-fate/common-fate v0.15.13 // indirect - github.com/common-fate/iso8601 v1.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/deepmap/oapi-codegen v1.11.0 // indirect - github.com/getkin/kin-openapi v0.107.0 // indirect - github.com/go-chi/chi/v5 v5.1.0 // indirect - github.com/go-jose/go-jose/v4 v4.0.5 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.19.6 // indirect - github.com/go-openapi/swag v0.22.4 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.11 // indirect - github.com/invopop/yaml v0.2.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/muhlemmer/gu v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.3.1 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - github.com/zitadel/logging v0.6.0 // indirect - github.com/zitadel/oidc/v3 v3.26.0 // indirect - github.com/zitadel/schema v1.3.0 // indirect - go.opentelemetry.io/otel v1.28.0 // indirect - go.opentelemetry.io/otel/metric v1.28.0 // indirect - go.opentelemetry.io/otel/trace v1.28.0 // indirect + go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/oauth2 v0.21.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) require ( diff --git a/go.sum b/go.sum index c8a0998c..a33232be 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -connectrpc.com/connect v1.14.0 h1:PDS+J7uoz5Oui2VEOMcfz6Qft7opQM9hPiKvtGC01pA= -connectrpc.com/connect v1.14.0/go.mod h1:uoAq5bmhhn43TwhaKdGKN/bZcGtzPW1v+ngDTn5u+8s= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= @@ -48,26 +46,12 @@ github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= -github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= github.com/common-fate/awsconfigfile v0.10.0 h1:9W0JTeO0d3jNLw3Ps9U7IJwLYp4D9zcipq/sqNEWJOg= github.com/common-fate/awsconfigfile v0.10.0/go.mod h1:znstvN26aO+KUwmdjwZ+WcmitZ7heEJb5iFdCPokAO8= -github.com/common-fate/cli v1.8.0 h1:T3I+NCMTyvIlZC8QK9qfmsZWj3eSDSZRPHQlM5KJ8Q4= -github.com/common-fate/cli v1.8.0/go.mod h1:fE4jNXj30AvqFvBMTHIDuoI/IahN1h8iRrjEE2n2Td0= github.com/common-fate/clio v1.2.3 h1:hHwUYZjn66qGYDpgANl0EB/92hyi/Jsnd07qB09rvn4= github.com/common-fate/clio v1.2.3/go.mod h1:NkozaS15SA+6Y9zb+82eIj1i41aWShorTqA01GKQ7A8= -github.com/common-fate/common-fate v0.15.13 h1:7u4ik6yaodyClAx4J/HTY8neJC06h9QquLtYgYNFuuU= -github.com/common-fate/common-fate v0.15.13/go.mod h1:VttFtdUzSEPLU5BTnePaGae99+Q6OKjYvY22EcSLyQ0= -github.com/common-fate/glide-cli v0.6.0 h1:MYnODLkK2KthskUNbo6ir7y3xMFGQD9eHvFlBMIWQ/k= -github.com/common-fate/glide-cli v0.6.0/go.mod h1:ddp1UKGg0evzweWP2yVif3KTO19JWXg5+LjjmtpeE0U= -github.com/common-fate/grab v1.3.0 h1:vGNBMfhAVAWtrLuH1stnhL4LsDb73drhegC/060q+Ok= -github.com/common-fate/grab v1.3.0/go.mod h1:6zH8GckZGFrOKfZzL4Y/2OTvxwFeL6cDtsztM0GGC2Y= -github.com/common-fate/iso8601 v1.1.0 h1:nrej9shsK1aB4IyOAjZl68xGk8yDuUxVwQjoDzxSK2c= -github.com/common-fate/iso8601 v1.1.0/go.mod h1:DU4mvUEkkWZUUSJq2aCuNqM1luSb0Pwyb2dLzXS+img= -github.com/common-fate/sdk v1.71.0 h1:SA+KZdbkOWBR6SrTculoUlALAGj6ftULdUPgr3Yw7RY= -github.com/common-fate/sdk v1.71.0/go.mod h1:OrXhzB2Y1JSrKGHrb4qRmY+6MF2M3MFb+3edBnessXo= github.com/common-fate/updatecheck v0.3.5 h1:UGIKMnYwuHjbhhCaisLz1pNPg8Z1nXEoWcfqT+4LkAg= github.com/common-fate/updatecheck v0.3.5/go.mod h1:fru9yoUXmM3QVAUdDDqKQeDoln20Pkji/7EH64gVHMs= github.com/common-fate/useragent v0.1.0 h1:RLmkIiJXcOUJAUyXWc/zCaGbrGmlCbHBGMx99ztQ3ZU= @@ -77,76 +61,25 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= -github.com/deepmap/oapi-codegen v1.11.0 h1:f/X2NdIkaBKsSdpeuwLnY/vDI0AtPUrmB5LMgc7YD+A= -github.com/deepmap/oapi-codegen v1.11.0/go.mod h1:k+ujhoQGxmQYBZBbxhOZNZf4j08qv5mC+OH+fFTnKxM= github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA= github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/getkin/kin-openapi v0.94.0/go.mod h1:LWZfzOd7PRy8GJ1dJ6mCU6tNdSfOwRac1BUPam4aw6Q= -github.com/getkin/kin-openapi v0.107.0 h1:bxhL6QArW7BXQj8NjXfIJQy680NsMKd25nwhvpCXchg= -github.com/getkin/kin-openapi v0.107.0/go.mod h1:9Dhr+FasATJZjS4iOLvB0hkaxgYdulrNYm2e9epLWOo= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= -github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= -github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= -github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= -github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= -github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= -github.com/go-playground/validator/v10 v10.11.0/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= -github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsDhiblTUXHxcOPwQSCzi7xpQUN4= @@ -159,55 +92,22 @@ github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4 github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= -github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= -github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= -github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= -github.com/jeremija/gosubmit v0.2.7 h1:At0OhGCFGPXyjPYAsCchoBUhE099pcBXmsb4iZqROIc= -github.com/jeremija/gosubmit v0.2.7/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.7.2/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks= -github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= -github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= -github.com/lestrrat-go/backoff/v2 v2.0.8/go.mod h1:rHP/q/r9aT27n24JQLa7JhSQZCKBBOiM/uP402WwN8Y= -github.com/lestrrat-go/blackmagic v1.0.0/go.mod h1:TNgH//0vYSs8VXDCfkZLgIrVTTXQELZffUV0tz3MtdQ= -github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= -github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc= -github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx v1.2.24/go.mod h1:zoNuZymNl5lgdcu6P7K6ie2QRll5HVfF4xwxBBK1NxY= -github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/matryer/moq v0.2.7/go.mod h1:kITsx543GOENm48TUAQyJ9+SAvFSr7iGQXPoth/VUBk= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -225,18 +125,8 @@ github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMK github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= -github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= -github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= -github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= -github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= @@ -251,13 +141,9 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= -github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= @@ -268,50 +154,23 @@ github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08O github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= -github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= -github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank= -github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= -github.com/zitadel/oidc/v3 v3.26.0 h1:BG3OUK+JpuKz7YHJIyUxL5Sl2JV6ePkG42UP4Xv3J2w= -github.com/zitadel/oidc/v3 v3.26.0/go.mod h1:Cx6AYPTJO5q2mjqF3jaknbKOUjpq1Xui0SYvVhkKuXU= -github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= -github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= -go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= -go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= -go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= -go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= -go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= -go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= @@ -323,52 +182,26 @@ go.uber.org/ratelimit v0.3.0/go.mod h1:So5LG7CV1zWpY1sHe+DXTJqQvOx+FFPFaAs2SnoyB go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220513224357-95641704303c/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -382,48 +215,27 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/assume/assume.go b/pkg/assume/assume.go index f9ace849..7c7a7dd5 100644 --- a/pkg/assume/assume.go +++ b/pkg/assume/assume.go @@ -29,6 +29,8 @@ import ( "github.com/fwdcloudsec/granted/pkg/console" "github.com/fwdcloudsec/granted/pkg/forkprocess" "github.com/fwdcloudsec/granted/pkg/hook/accessrequesthook" + "github.com/fwdcloudsec/granted/pkg/hook/httpprovider" + "github.com/fwdcloudsec/granted/pkg/providercfg" "github.com/fwdcloudsec/granted/pkg/launcher" "github.com/fwdcloudsec/granted/pkg/testable" cfflags "github.com/fwdcloudsec/granted/pkg/urfav_overrides" @@ -36,7 +38,6 @@ import ( "github.com/hako/durafmt" sethRetry "github.com/sethvargo/go-retry" "github.com/urfave/cli/v2" - durationpb "google.golang.org/protobuf/types/known/durationpb" "gopkg.in/ini.v1" ) @@ -317,7 +318,6 @@ func AssumeCommand(c *cli.Context) error { wait := assumeFlags.Bool("wait") retryDuration := time.Minute * 1 if wait { - //if wait is specified, increase the timeout to 15 minutes. retryDuration = time.Minute * 15 } @@ -341,15 +341,22 @@ func AssumeCommand(c *cli.Context) error { creds, err := profile.AssumeConsole(c.Context, configOpts) if err != nil && strings.HasPrefix(err.Error(), "no access") { clio.Debugw("received a No Access error", "error", err) - hook := accessrequesthook.Hook{} - var apiDuration *durationpb.Duration + hook, hookCreateErr := accessrequesthook.NewHookFromProfile(profile, newHTTPProvider) + if hookCreateErr != nil { + return hookCreateErr + } + if hook == nil { + return err + } + + var apiDuration *time.Duration if duration != "" { d, err := time.ParseDuration(duration) if err != nil { return err } - apiDuration = durationpb.New(d) + apiDuration = &d } noAccessInput := accessrequesthook.NoAccessInput{ @@ -375,7 +382,6 @@ func AssumeCommand(c *cli.Context) error { err = sethRetry.Do(c.Context, b, func(ctx context.Context) (err error) { if !justActivated { - //also proactively check if request has been approved and attempt to activate err = hook.RetryAccess(ctx, noAccessInput) if err != nil { return sethRetry.RetryableError(err) @@ -507,15 +513,22 @@ func AssumeCommand(c *cli.Context) error { creds, err := profile.AssumeTerminal(c.Context, configOpts) if err != nil && strings.HasPrefix(err.Error(), "no access") { clio.Debugw("received a No Access error", "error", err) - hook := accessrequesthook.Hook{} - var apiDuration *durationpb.Duration + hook, hookCreateErr := accessrequesthook.NewHookFromProfile(profile, newHTTPProvider) + if hookCreateErr != nil { + return hookCreateErr + } + if hook == nil { + return err + } + + var apiDuration *time.Duration if duration != "" { d, err := time.ParseDuration(duration) if err != nil { return err } - apiDuration = durationpb.New(d) + apiDuration = &d } noAccessInput := accessrequesthook.NoAccessInput{ Profile: profile, @@ -539,7 +552,6 @@ func AssumeCommand(c *cli.Context) error { err = sethRetry.Do(c.Context, b, func(ctx context.Context) (err error) { if !justActivated { - //also proactively check if request has been approved and attempt to activate err = hook.RetryAccess(ctx, noAccessInput) if err != nil { return sethRetry.RetryableError(err) @@ -722,6 +734,14 @@ func EnvKeys(creds aws.Credentials, region string) []string { "AWS_REGION=" + region} } +func newHTTPProvider(providerURL string) (accessrequesthook.AccessProvider, error) { + cfg, err := providercfg.LoadFromURL(context.Background(), providerURL) + if err != nil { + return nil, err + } + return httpprovider.New(cfg), nil +} + func filterMultiToken(filterValue string, optValue string, optIndex int) bool { optValue = strings.ToLower(optValue) filters := strings.Split(strings.ToLower(filterValue), " ") diff --git a/pkg/cfcfg/cfcfg.go b/pkg/cfcfg/cfcfg.go deleted file mode 100644 index d840b09d..00000000 --- a/pkg/cfcfg/cfcfg.go +++ /dev/null @@ -1,82 +0,0 @@ -package cfcfg - -import ( - "context" - "fmt" - "net/url" - - "github.com/common-fate/clio" - "github.com/fwdcloudsec/granted/pkg/cfaws" - sdkconfig "github.com/common-fate/sdk/config" -) - -func GetCommonFateURL(profile *cfaws.Profile) (*url.URL, error) { - if profile == nil { - clio.Debugw("skipping loading Common Fate SDK from URL", "reason", "profile was nil") - return nil, nil - } - if profile.RawConfig == nil { - clio.Debugw("skipping loading Common Fate SDK from URL", "reason", "profile.RawConfig was nil") - return nil, nil - } - if !profile.RawConfig.HasKey("common_fate_url") { - clio.Debugw("skipping loading Common Fate SDK from URL", "reason", "profile does not have key common_fate_url", "profile_keys", profile.RawConfig.KeyStrings()) - return nil, nil - } - key, err := profile.RawConfig.GetKey("common_fate_url") - if err != nil { - return nil, err - } - - u, err := url.Parse(key.Value()) - if err != nil { - return nil, fmt.Errorf("invalid common_fate_url (%s): %w", key.Value(), err) - } - - return u, nil -} - -func GenerateRequestURL(apiURL string, requestID string) (string, error) { - u, err := url.Parse(apiURL) - if err != nil { - return "", err - } - p := u.JoinPath("access", "requests", requestID) - return p.String(), nil -} - -func Load(ctx context.Context, profile *cfaws.Profile) (*sdkconfig.Context, error) { - cfURL, err := GetCommonFateURL(profile) - if err != nil { - return nil, err - } - - if cfURL != nil { - cfURL = cfURL.JoinPath("config.json") - - clio.Debugw("configuring Common Fate SDK from URL", "url", cfURL.String()) - - return sdkconfig.New(ctx, sdkconfig.Opts{ - ConfigSources: []string{cfURL.String()}, - }) - } else { - // if we can't load the Common Fate SDK config (e.g. if `~/.cf/config` is not present) - // we can't request access through the Common Fate platform. - return sdkconfig.LoadDefault(ctx) - - } -} - -func LoadURL(ctx context.Context, cfURL string) (*sdkconfig.Context, error) { - u, err := url.Parse(cfURL) - if err != nil { - return nil, err - } - u = u.JoinPath("config.json") - - clio.Debugw("configuring Common Fate SDK from URL", "url", u.String()) - - return sdkconfig.New(ctx, sdkconfig.Opts{ - ConfigSources: []string{u.String()}, - }) -} diff --git a/pkg/granted/auth/auth.go b/pkg/granted/auth/auth.go index 1b7b855a..74e722af 100644 --- a/pkg/granted/auth/auth.go +++ b/pkg/granted/auth/auth.go @@ -1,10 +1,10 @@ package auth import ( - "github.com/common-fate/cli/cmd/cli/command" + "fmt" + "github.com/common-fate/clio" - "github.com/common-fate/sdk/config" - "github.com/common-fate/sdk/loginflow" + "github.com/fwdcloudsec/granted/pkg/providercfg" "github.com/urfave/cli/v2" ) @@ -13,43 +13,45 @@ var Command = cli.Command{ Usage: "Manage OIDC authentication for Granted", Flags: []cli.Flag{}, Subcommands: []*cli.Command{ - &command.Configure, &loginCommand, &logoutCommand, - &command.Context, }, } var loginCommand = cli.Command{ Name: "login", - Usage: "Authenticate to an OIDC provider", + Usage: "Authenticate to an access provider", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "url", Usage: "The access provider URL to authenticate with"}, + }, Action: func(c *cli.Context) error { - cfg, err := config.LoadDefault(c.Context) - if err == config.ErrConfigFileNotFound { - clio.Errorf("The Common Fate config file (~/.cf/config by default) was not found. To fix this, run 'granted auth configure https://commonfate.example.com' (replacing the URL in the command with your Common Fate deployment URL") + providerURL := c.String("url") + if providerURL == "" { + providerURL = c.Args().First() } + if providerURL == "" { + return fmt.Errorf("please provide a provider URL, e.g. 'granted auth login https://provider.example.com'") + } + + cfg, err := providercfg.LoadFromURL(c.Context, providerURL) if err != nil { - return err + return fmt.Errorf("failed to load provider config from %s: %w", providerURL, err) } - lf := loginflow.NewFromConfig(cfg) + // TODO: implement OIDC login flow using cfg.Auth + clio.Infof("Provider config loaded from %s (auth type: %s, issuer: %s)", providerURL, cfg.Auth.Type, cfg.Auth.Issuer) + clio.Warn("OIDC login flow is not yet implemented. Please authenticate via your browser.") - return lf.Login(c.Context) + return nil }, } var logoutCommand = cli.Command{ Name: "logout", - Usage: "Log out of an OIDC provider", + Usage: "Log out of an access provider", Action: func(c *cli.Context) error { - cfg, err := config.LoadDefault(c.Context) - if err == config.ErrConfigFileNotFound { - clio.Errorf("The Common Fate config file (~/.cf/config by default) was not found. To fix this, run 'granted auth configure https://commonfate.example.com' (replacing the URL in the command with your Common Fate deployment URL") - } - if err != nil { - return err - } - - return cfg.TokenStore.Clear() + // TODO: implement logout (clear stored tokens) + clio.Info("Logout is not yet implemented") + return nil }, } diff --git a/pkg/granted/credential_process.go b/pkg/granted/credential_process.go index 35c02eca..dcc1e897 100644 --- a/pkg/granted/credential_process.go +++ b/pkg/granted/credential_process.go @@ -6,19 +6,12 @@ import ( "fmt" "time" - "connectrpc.com/connect" "github.com/aws/aws-sdk-go-v2/aws" "github.com/pkg/errors" "github.com/common-fate/clio" - "github.com/common-fate/grab" - "github.com/common-fate/sdk/eid" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - "github.com/common-fate/sdk/service/access/grants" - identitysvc "github.com/common-fate/sdk/service/identity" "github.com/fwdcloudsec/granted/pkg/accessrequest" "github.com/fwdcloudsec/granted/pkg/cfaws" - "github.com/fwdcloudsec/granted/pkg/cfcfg" "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/securestorage" sethRetry "github.com/sethvargo/go-retry" @@ -60,7 +53,6 @@ var CredentialProcess = cli.Command{ useCache := !cfg.DisableCredentialProcessCache && !cliNoCache if useCache { - // try and look up session credentials from the secure storage cache. cachedCreds, err := secureSessionCredentialStorage.GetCredentials(profileName) if err != nil { clio.Debugw("error loading cached credentials", "error", err, "profile", profileName) @@ -69,7 +61,6 @@ var CredentialProcess = cli.Command{ } else if cachedCreds.CanExpire && cachedCreds.Expires.Add(-c.Duration("window")).Before(time.Now()) { clio.Debugw("refreshing credentials", "reason", "credentials are expired") } else { - // if we get here, the cached session credentials are valid clio.Debugw("credentials found in cache", "expires", cachedCreds.Expires.String(), "canExpire", cachedCreds.CanExpire, "timeNow", time.Now().String(), "refreshIfBeforeNow", cachedCreds.Expires.Add(-c.Duration("window")).String()) return printCredentials(*cachedCreds) } @@ -79,7 +70,6 @@ var CredentialProcess = cli.Command{ clio.Debugw("refreshing credentials", "reason", "credential process cache is disabled via config") } - // purge the credentials from the cache err = secureSessionCredentialStorage.SecureStorage.Clear(profileName) if err != nil { clio.Debugw("error clearing cached credentials", "error", err, "profile", profileName) @@ -102,71 +92,25 @@ var CredentialProcess = cli.Command{ credentials, err := profile.AssumeTerminal(c.Context, cfaws.ConfigOpts{Duration: duration, UsingCredentialProcess: true, CredentialProcessAutoLogin: autoLogin, UseAuthorizationCode: cfg.UseAuthorizationCode}) if err != nil { - // We first check if there was an active grant for this profile, and if there was, allow 30s of retries before bailing out - cfg, cfConfigErr := cfcfg.Load(c.Context, profile) - if cfConfigErr != nil { - clio.Debugw("failed to load cfconfig, skipping check for active grants in a common fate deployment", "error", cfConfigErr) - return err - } - - grantsClient := grants.NewFromConfig(cfg) - idClient := identitysvc.NewFromConfig(cfg) - callerID, callerIDErr := idClient.GetCallerIdentity(c.Context, connect.NewRequest(&accessv1alpha1.GetCallerIdentityRequest{})) - if callerIDErr != nil { - clio.Debugw("failed to load caller identity for user", "error", callerIDErr) - // return the original error - return err - } - grants, queryGrantsErr := grab.AllPages(c.Context, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Grant, *string, error) { - grants, err := grantsClient.QueryGrants(c.Context, connect.NewRequest(&accessv1alpha1.QueryGrantsRequest{ - Principal: callerID.Msg.Principal.Eid, - Target: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), - // This API needs to be updated to use specifiers, for now, fetch all active grants and check for a match on the role name - // Role: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), - Status: accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE.Enum(), - })) - if err != nil { - return nil, nil, err - } - return grants.Msg.Grants, &grants.Msg.NextPageToken, nil - }) - - if queryGrantsErr != nil { - clio.Debugw("failed to query for active grants", "error", queryGrantsErr) - // return the original error - return err - } + clio.Debugw("initial assume failed, attempting retry with backoff", "error", err) - var foundActiveGrant bool - for _, grant := range grants { - if grant.Role.Name == profile.AWSConfig.SSORoleName { - clio.Debugw("found active grant matching the profile, will retry assuming role", "grant", grant) - foundActiveGrant = true - break - } - } - if !foundActiveGrant { - clio.Debug("did not find any matching active grants for the profile, will not retry assuming role") - clio.Debugw("could not assume role due to the following error, notifying user to try requesting access", "error", err) - err := accessrequest.Profile{Name: profileName}.Save() - if err != nil { - return err - } - return errors.New("You don't have access but you can request it with 'granted request latest'") - } - - // there is an active grant so retry assuming because the error may be transient + // Retry with exponential backoff in case of transient errors b := sethRetry.NewFibonacci(time.Second) b = sethRetry.WithMaxDuration(time.Second*30, b) - err = sethRetry.Do(c.Context, b, func(ctx context.Context) (err error) { + retryErr := sethRetry.Do(c.Context, b, func(ctx context.Context) (err error) { credentials, err = profile.AssumeTerminal(c.Context, cfaws.ConfigOpts{Duration: duration, UsingCredentialProcess: true, CredentialProcessAutoLogin: autoLogin}) if err != nil { return sethRetry.RetryableError(err) } return nil }) - if err != nil { - return err + if retryErr != nil { + clio.Debugw("could not assume role after retries, notifying user to try requesting access", "error", err) + saveErr := accessrequest.Profile{Name: profileName}.Save() + if saveErr != nil { + return saveErr + } + return errors.New("You don't have access but you can request it with 'granted request latest'") } } if !cfg.DisableCredentialProcessCache { diff --git a/pkg/granted/entrypoint.go b/pkg/granted/entrypoint.go index cf65f4d4..d629cd6a 100644 --- a/pkg/granted/entrypoint.go +++ b/pkg/granted/entrypoint.go @@ -8,7 +8,6 @@ import ( "github.com/common-fate/clio" "github.com/common-fate/clio/cliolog" - "github.com/common-fate/glide-cli/cmd/command" "github.com/common-fate/useragent" "github.com/fwdcloudsec/granted/internal/build" "github.com/fwdcloudsec/granted/pkg/chromemsg" @@ -19,8 +18,6 @@ import ( "github.com/fwdcloudsec/granted/pkg/granted/registry" "github.com/fwdcloudsec/granted/pkg/granted/request" "github.com/fwdcloudsec/granted/pkg/granted/settings" - "github.com/fwdcloudsec/granted/pkg/securestorage" - "github.com/pkg/errors" "github.com/urfave/cli/v2" "go.uber.org/zap" ) @@ -116,22 +113,13 @@ func GetCliApp() *cli.App { var login = cli.Command{ Name: "login", - Usage: "Log in to Glide [deprecated]", + Usage: "Log in to an access provider [deprecated: use granted auth login]", Flags: []cli.Flag{ &cli.BoolFlag{Name: "lazy", Usage: "When the lazy flag is used, a login flow will only be started when the access token is expired"}, }, Action: func(c *cli.Context) error { clio.Warn("this command is deprecated and will be removed in a future release") - clio.Warn("use granted auth login if you are trying to authenticate with a Common Fate deployment") - - k, err := securestorage.NewCF().Storage.Keyring() - if err != nil { - return errors.Wrap(err, "loading keyring") - } - - // wrap the nested CLI command with the keyring - lf := command.LoginFlow{Keyring: k} - - return lf.LoginAction(c) + clio.Warn("use 'granted auth login ' to authenticate with an access provider") + return nil }, } diff --git a/pkg/granted/registry/add.go b/pkg/granted/registry/add.go index b003606c..ef42e9fd 100644 --- a/pkg/granted/registry/add.go +++ b/pkg/granted/registry/add.go @@ -8,7 +8,7 @@ import ( "github.com/common-fate/clio" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/granted/awsmerge" - "github.com/fwdcloudsec/granted/pkg/granted/registry/cfregistry" + "github.com/fwdcloudsec/granted/pkg/granted/registry/httpregistry" "github.com/fwdcloudsec/granted/pkg/granted/registry/gitregistry" "github.com/fwdcloudsec/granted/pkg/testable" @@ -165,7 +165,7 @@ var AddCommand = cli.Command{ return nil } else { - registry := cfregistry.New(cfregistry.Opts{ + registry := httpregistry.New(httpregistry.Opts{ Name: name, URL: URL, }) diff --git a/pkg/granted/registry/cfregistry/cfregistry.go b/pkg/granted/registry/cfregistry/cfregistry.go deleted file mode 100644 index edb209de..00000000 --- a/pkg/granted/registry/cfregistry/cfregistry.go +++ /dev/null @@ -1,156 +0,0 @@ -package cfregistry - -import ( - "context" - "fmt" - "strings" - "sync" - - "connectrpc.com/connect" - "github.com/common-fate/clio" - "github.com/fwdcloudsec/granted/pkg/cfcfg" - awsv1alpha1 "github.com/common-fate/sdk/gen/granted/registry/aws/v1alpha1" - "github.com/common-fate/sdk/gen/granted/registry/aws/v1alpha1/awsv1alpha1connect" - "github.com/common-fate/sdk/loginflow" - grantedv1alpha1 "github.com/common-fate/sdk/service/granted/registry" - "gopkg.in/ini.v1" -) - -type Registry struct { - opts Opts - mu sync.Mutex - // client is the profile registry service client. - // - // Do not use client directly. Instead, call - // r.getClient() which will automatically populate it. - client awsv1alpha1connect.ProfileRegistryServiceClient -} - -type Opts struct { - Name string - URL string -} - -// getClient lazily loads the Profile Registry service client. -// -// Becuase the Registry is constructed every time the Granted CLI executes, -// calling `config.LoadDefault()` when creating the registry makes Granted very slow. -// Instead, we only obtain an OIDC token if we actually need to load profiles for the registry. -func (r *Registry) getClient(interactive bool) (awsv1alpha1connect.ProfileRegistryServiceClient, error) { - // if the cached - if r.client != nil { - return r.client, nil - } - - // Load the config from the deployment URL - cfg, err := cfcfg.LoadURL(context.Background(), r.opts.URL) - if err != nil { - // NOTE(josh): ideally we'll bubble up a more strongly typed error in future here, to avoid the string comparison on the error message. - // the OAuth2.0 token is expired so we should prompt the user to log in - if needsToRefreshLogin(err) { - if interactive { - clio.Infof("You need to log into Common Fate to sync your profile registry") - lf := loginflow.NewFromConfig(cfg) - err = lf.Login(context.Background()) - if err != nil { - return nil, err - } - } else { - // in non interactive mode, just return a wrapped error - return nil, fmt.Errorf("you need to log into Common Fate to sync your profile registry using `granted auth login`: %w", err) - } - - } else { - return nil, err - } - } - - accountClient := grantedv1alpha1.NewFromConfig(cfg) - - r.mu.Lock() - defer r.mu.Unlock() - r.client = accountClient - - return r.client, nil -} -func needsToRefreshLogin(err error) bool { - if err == nil { - return false - } - if strings.Contains(err.Error(), "oauth2: token expired") { - return true - } - if strings.Contains(err.Error(), "oauth2: invalid grant") { - return true - } - // Sanity check that error message is matching correctly - if strings.Contains(err.Error(), `oauth2: "token_expired"`) { - return true - } - if strings.Contains(err.Error(), `oauth2: "invalid_grant"`) { - return true - } - - return false -} - -func New(opts Opts) *Registry { - r := Registry{ - opts: opts, - } - - return &r -} - -func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File, error) { - client, err := r.getClient(interactive) - if err != nil { - return nil, err - } - - // call the Profile Registry API to pull the avilable profiles. - done := false - var pageToken string - profiles := []*awsv1alpha1.Profile{} - - for !done { - listProfiles, err := client.ListProfiles(ctx, &connect.Request[awsv1alpha1.ListProfilesRequest]{ - Msg: &awsv1alpha1.ListProfilesRequest{ - PageToken: pageToken, - }, - }) - if err != nil { - return nil, err - } - - profiles = append(profiles, listProfiles.Msg.Profiles...) - - if listProfiles.Msg.NextPageToken == "" { - done = true - } else { - pageToken = listProfiles.Msg.NextPageToken - } - } - - result := ini.Empty() - - for _, profile := range profiles { - - section, err := result.NewSection(profile.Name) - if err != nil { - return nil, err - } - - //expect all the attributes to come from the api with the correct key value pairs - for _, attr := range profile.Attributes { - _, err := section.NewKey(attr.Key, attr.Value) - if err != nil { - return nil, err - } - - } - - } - - return result, nil -} diff --git a/pkg/granted/registry/httpregistry/httpregistry.go b/pkg/granted/registry/httpregistry/httpregistry.go new file mode 100644 index 00000000..e650be94 --- /dev/null +++ b/pkg/granted/registry/httpregistry/httpregistry.go @@ -0,0 +1,133 @@ +package httpregistry + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/providercfg" + "gopkg.in/ini.v1" +) + +type Registry struct { + opts Opts + mu sync.Mutex + cfg *providercfg.ProviderConfig +} + +type Opts struct { + Name string + URL string +} + +// getConfig lazily loads the provider configuration. +// This avoids slowing down Granted startup when the registry isn't needed. +func (r *Registry) getConfig(interactive bool) (*providercfg.ProviderConfig, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.cfg != nil { + return r.cfg, nil + } + + cfg, err := providercfg.LoadFromURL(context.Background(), r.opts.URL) + if err != nil { + if interactive { + clio.Warnf("Failed to load provider config from %s: %s", r.opts.URL, err) + } + return nil, err + } + + r.cfg = cfg + return r.cfg, nil +} + +func New(opts Opts) *Registry { + return &Registry{opts: opts} +} + +type listProfilesResponse struct { + Profiles []profileEntry `json:"profiles"` + NextPageToken string `json:"next_page_token"` +} + +type profileEntry struct { + Name string `json:"name"` + Attributes []profileKeyVal `json:"attributes"` +} + +type profileKeyVal struct { + Key string `json:"key"` + Value string `json:"value"` +} + +func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File, error) { + cfg, err := r.getConfig(interactive) + if err != nil { + return nil, err + } + + client := &http.Client{Timeout: 30 * time.Second} + + var allProfiles []profileEntry + var pageToken string + + for { + listURL := fmt.Sprintf("%s/v1/registry/profiles", cfg.APIURL) + if pageToken != "" { + listURL += "?page_token=" + pageToken + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching profiles from %s: %w", listURL, err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("profile registry returned HTTP %d from %s", resp.StatusCode, listURL) + } + + var listResp listProfilesResponse + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + resp.Body.Close() + return nil, fmt.Errorf("decoding profile list from %s: %w", listURL, err) + } + resp.Body.Close() + + allProfiles = append(allProfiles, listResp.Profiles...) + + if listResp.NextPageToken == "" { + break + } + pageToken = listResp.NextPageToken + } + + result := ini.Empty() + + for _, profile := range allProfiles { + section, err := result.NewSection(profile.Name) + if err != nil { + return nil, err + } + + for _, attr := range profile.Attributes { + _, err := section.NewKey(attr.Key, attr.Value) + if err != nil { + return nil, err + } + } + } + + return result, nil +} diff --git a/pkg/granted/registry/registry.go b/pkg/granted/registry/registry.go index b3a77677..01d21b4d 100644 --- a/pkg/granted/registry/registry.go +++ b/pkg/granted/registry/registry.go @@ -5,7 +5,7 @@ import ( "sort" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" - "github.com/fwdcloudsec/granted/pkg/granted/registry/cfregistry" + "github.com/fwdcloudsec/granted/pkg/granted/registry/httpregistry" "github.com/fwdcloudsec/granted/pkg/granted/registry/gitregistry" "gopkg.in/ini.v1" ) @@ -50,8 +50,7 @@ func GetProfileRegistries(interactive bool) ([]loadedRegistry, error) { Registry: reg, }) } else { - //set up a common fate registry - reg := cfregistry.New(cfregistry.Opts{ + reg := httpregistry.New(httpregistry.Opts{ Name: r.Name, URL: r.URL, }) diff --git a/pkg/granted/request/check.go b/pkg/granted/request/check.go deleted file mode 100644 index d548bd11..00000000 --- a/pkg/granted/request/check.go +++ /dev/null @@ -1,101 +0,0 @@ -package request - -import ( - "context" - "fmt" - - "connectrpc.com/connect" - "github.com/common-fate/clio" - "github.com/common-fate/grab" - "github.com/fwdcloudsec/granted/pkg/cfaws" - "github.com/fwdcloudsec/granted/pkg/cfcfg" - "github.com/fwdcloudsec/granted/pkg/securestorage" - "github.com/common-fate/sdk/eid" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - "github.com/common-fate/sdk/service/access/grants" - identitysvc "github.com/common-fate/sdk/service/identity" - "github.com/urfave/cli/v2" -) - -var checkCommand = cli.Command{ - Name: "check", - Usage: "Check the Common Fate JIT backend to see whether Just-In-Time access to a particular entitlement is active", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "aws-profile", Required: true, Usage: "Check for access for a particular AWS profile"}, - }, - Action: func(c *cli.Context) error { - profiles, err := cfaws.LoadProfiles() - if err != nil { - return err - } - - profileName := c.String("aws-profile") - - profile, err := profiles.LoadInitialisedProfile(c.Context, profileName) - if err != nil { - return err - } - - cfg, err := cfcfg.Load(c.Context, profile) - if err != nil { - return fmt.Errorf("failed to load cfconfig, cannot check for active grants, %w", err) - } - - grantsClient := grants.NewFromConfig(cfg) - idClient := identitysvc.NewFromConfig(cfg) - callerID, err := idClient.GetCallerIdentity(c.Context, connect.NewRequest(&accessv1alpha1.GetCallerIdentityRequest{})) - if err != nil { - return err - } - target := eid.New("AWS::Account", profile.AWSConfig.SSOAccountID) - - grants, err := grab.AllPages(c.Context, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Grant, *string, error) { - grants, err := grantsClient.QueryGrants(c.Context, connect.NewRequest(&accessv1alpha1.QueryGrantsRequest{ - Principal: callerID.Msg.Principal.Eid, - Target: target.ToAPI(), - // This API needs to be updated to use specifiers, for now, fetch all active grants and check for a match on the role name - // Role: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), - Status: accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE.Enum(), - })) - if err != nil { - return nil, nil, err - } - return grants.Msg.Grants, &grants.Msg.NextPageToken, nil - }) - - if err != nil { - clearCacheProfileIfExists(profileName) - return fmt.Errorf("failed to query for active grants: %w", err) - } - - for _, grant := range grants { - if grant.Role.Name == profile.AWSConfig.SSORoleName { - clio.Debugw("found active grant matching the profile, will retry assuming role", "grant", grant) - clio.Successf("access to target %s and role %s is currently active", target, profile.AWSConfig.SSORoleName) - fmt.Println(grant.AccessRequestId) - return nil - } - } - - // no active Access Request exists, so the session token cache should be cleared for the profile. - clearCacheProfileIfExists(profileName) - - return fmt.Errorf("no active Access Request found for target %s and role %s", target, profile.AWSConfig.SSORoleName) - }, -} - -func clearCacheProfileIfExists(profile string) { - cache := securestorage.NewSecureSessionCredentialStorage() - found, err := cache.SecureStorage.HasKey(profile) - if err != nil { - clio.Errorf("error checking cache for profile %q: %s", profile, err) - } - if !found { - return - } - - err = cache.SecureStorage.Clear(profile) - if err != nil { - clio.Errorf("error clearing cache for profile %q: %s", profile, err) - } -} diff --git a/pkg/granted/request/close.go b/pkg/granted/request/close.go deleted file mode 100644 index 9f7317bc..00000000 --- a/pkg/granted/request/close.go +++ /dev/null @@ -1,201 +0,0 @@ -package request - -import ( - "context" - "fmt" - - "connectrpc.com/connect" - "github.com/AlecAivazis/survey/v2" - "github.com/common-fate/cli/printdiags" - "github.com/common-fate/clio" - "github.com/common-fate/grab" - "github.com/fwdcloudsec/granted/pkg/cfaws" - "github.com/fwdcloudsec/granted/pkg/cfcfg" - "github.com/fwdcloudsec/granted/pkg/testable" - "github.com/common-fate/sdk/config" - "github.com/common-fate/sdk/eid" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - entityv1alpha1 "github.com/common-fate/sdk/gen/commonfate/entity/v1alpha1" - "github.com/common-fate/sdk/service/access/grants" - "github.com/common-fate/sdk/service/access/request" - identitysvc "github.com/common-fate/sdk/service/identity" - "github.com/urfave/cli/v2" -) - -var closeCommand = cli.Command{ - Name: "close", - Usage: "Close an active Just-In-Time access to a particular entitlement", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "profile", Required: false, Usage: "Close a JIT access for a particular AWS profile"}, - &cli.StringFlag{Name: "request-id", Required: false, Usage: "Close a JIT access for a particular access request ID"}, - }, - Action: func(c *cli.Context) error { - - accessRequestID := c.String("request-id") - profileName := c.String("profile") - - if accessRequestID != "" && profileName != "" { - clio.Warn("Both profile and request-id were provided, profile will be ignored") - } - - if accessRequestID != "" { - ctx := c.Context - - cfg, err := config.LoadDefault(ctx) - if err != nil { - return err - } - - client := request.NewFromConfig(cfg) - - closeRes, err := client.CloseAccessRequest(ctx, connect.NewRequest(&accessv1alpha1.CloseAccessRequestRequest{ - Id: accessRequestID, - })) - clio.Debugw("result", "closeAccessRequest", closeRes) - if err != nil { - return fmt.Errorf("failed to close access request: , %w", err) - } - - haserrors := printdiags.Print(closeRes.Msg.Diagnostics, nil) - if !haserrors { - clio.Successf("access request %s is now closed", accessRequestID) - } - - return nil - } - - if profileName != "" { - - profiles, err := cfaws.LoadProfiles() - if err != nil { - return err - } - - profile, err := profiles.LoadInitialisedProfile(c.Context, profileName) - if err != nil { - return err - } - - cfg, err := cfcfg.Load(c.Context, profile) - if err != nil { - return fmt.Errorf("failed to load cfconfig, cannot check for active grants, %w", err) - } - - grantsClient := grants.NewFromConfig(cfg) - idClient := identitysvc.NewFromConfig(cfg) - callerID, err := idClient.GetCallerIdentity(c.Context, connect.NewRequest(&accessv1alpha1.GetCallerIdentityRequest{})) - if err != nil { - return err - } - target := eid.New("AWS::Account", profile.AWSConfig.SSOAccountID) - - grants, err := grab.AllPages(c.Context, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Grant, *string, error) { - grants, err := grantsClient.QueryGrants(c.Context, connect.NewRequest(&accessv1alpha1.QueryGrantsRequest{ - Principal: callerID.Msg.Principal.Eid, - Target: target.ToAPI(), - // This API needs to be updated to use specifiers, for now, fetch all active grants and check for a match on the role name - // Role: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), - Status: accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE.Enum(), - })) - if err != nil { - return nil, nil, err - } - return grants.Msg.Grants, &grants.Msg.NextPageToken, nil - }) - - if err != nil { - clearCacheProfileIfExists(profileName) - return fmt.Errorf("failed to query for active grants: %w", err) - } - - accessClient := request.NewFromConfig(cfg) - - for _, grant := range grants { - if grant.Role.Name == profile.AWSConfig.SSORoleName { - clio.Debugw("found active grant matching the profile, attempting to close grant", "grant", grant) - - res, err := accessClient.CloseAccessRequest(c.Context, connect.NewRequest(&accessv1alpha1.CloseAccessRequestRequest{ - Id: grant.AccessRequestId, - })) - clio.Debugw("result", "res", res) - if err != nil { - return err - } - clio.Successf("access to target %s and role %s is now closed", target, profile.AWSConfig.SSORoleName) - return nil - } - } - - return fmt.Errorf("no active Access Request found for target %s and role %s", target, profile.AWSConfig.SSORoleName) - } - - // Prompt the user with a list of active access requests if no flags are set - ctx := c.Context - cfg, err := config.LoadDefault(ctx) - if err != nil { - return err - } - accessClient := request.NewFromConfig(cfg) - - res, err := accessClient.QueryMyAccessRequests(ctx, connect.NewRequest(&accessv1alpha1.QueryMyAccessRequestsRequest{ - Order: entityv1alpha1.Order_ORDER_DESCENDING.Enum(), - })) - clio.Debugw("result", "res", res) - if err != nil { - return err - } - - userAccessRequests := res.Msg.AccessRequests - if len(res.Msg.AccessRequests) == 0 { - clio.Error("There are no access requests that need to be closed") - return nil - } - - accessRequestsWithNames := []string{} - for _, req := range userAccessRequests { - // For now, add temporary code to check if the access request has granted that need to be closed - // This part will be replaced by the implementation of the GrantStatus filter within QueryAccessRequests - needsDeprovisioning := false - for _, grant := range req.Grants { - - if grant.Status == accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE && grant.ProvisioningStatus != accessv1alpha1.ProvisioningStatus(accessv1alpha1.ProvisioningStatus_PROVISIONING_STATUS_ATTEMPTING) { - needsDeprovisioning = true - break - } - } - if needsDeprovisioning { - accessRequestsWithNames = append(accessRequestsWithNames, req.Id) - } - } - - in := survey.Select{Message: "Please select the access request that you would like to close:", Options: accessRequestsWithNames} - var out string - err = testable.AskOne(&in, &out) - if err != nil { - return err - } - - var selectedAccessRequest string - - for _, r := range userAccessRequests { - if r.Id == out { - selectedAccessRequest = r.Id - } - } - - closeRes, err := accessClient.CloseAccessRequest(ctx, connect.NewRequest(&accessv1alpha1.CloseAccessRequestRequest{ - Id: selectedAccessRequest, - })) - clio.Debugw("result", "closeAccessRequest", closeRes) - if err != nil { - return fmt.Errorf("failed to close access request: , %w", err) - } - - haserrors := printdiags.Print(closeRes.Msg.Diagnostics, nil) - if !haserrors { - clio.Successf("access request %s is now closed", selectedAccessRequest) - } - - return nil - }, -} diff --git a/pkg/granted/request/request.go b/pkg/granted/request/request.go index 41ee861a..fa5ec732 100644 --- a/pkg/granted/request/request.go +++ b/pkg/granted/request/request.go @@ -2,23 +2,15 @@ package request import ( "context" - "fmt" "time" - "connectrpc.com/connect" "github.com/common-fate/clio" - "github.com/common-fate/grab" "github.com/fwdcloudsec/granted/pkg/accessrequest" "github.com/fwdcloudsec/granted/pkg/cfaws" - "github.com/fwdcloudsec/granted/pkg/cfcfg" "github.com/fwdcloudsec/granted/pkg/hook/accessrequesthook" - "github.com/common-fate/sdk/eid" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - "github.com/common-fate/sdk/service/access/grants" - identitysvc "github.com/common-fate/sdk/service/identity" - "github.com/hako/durafmt" + "github.com/fwdcloudsec/granted/pkg/hook/httpprovider" + "github.com/fwdcloudsec/granted/pkg/providercfg" "github.com/urfave/cli/v2" - "google.golang.org/protobuf/types/known/durationpb" ) var Command = cli.Command{ @@ -26,11 +18,18 @@ var Command = cli.Command{ Usage: "Request access to a role", Subcommands: []*cli.Command{ &latestCommand, - &checkCommand, - &closeCommand, + // TODO: re-enable check and close commands with HTTP provider }, } +func newHTTPProvider(providerURL string) (accessrequesthook.AccessProvider, error) { + cfg, err := providercfg.LoadFromURL(context.Background(), providerURL) + if err != nil { + return nil, err + } + return httpprovider.New(cfg), nil +} + var latestCommand = cli.Command{ Name: "latest", Usage: "Request access to the latest AWS role you attempted to use", @@ -56,53 +55,21 @@ var latestCommand = cli.Command{ return err } - // We first check if there was an active grant for this profile, and if there was, allow 30s of retries before bailing out - cfg, cfConfigErr := cfcfg.Load(c.Context, profile) + hook, err := accessrequesthook.NewHookFromProfile(profile, newHTTPProvider) if err != nil { - if cfConfigErr != nil { - clio.Debugw("failed to load cfconfig, skipping check for active grants in a common fate deployment", "error", cfConfigErr) - } + clio.Debugw("failed to create access hook", "error", err) return err } - - grantsClient := grants.NewFromConfig(cfg) - idClient := identitysvc.NewFromConfig(cfg) - callerID, err := idClient.GetCallerIdentity(c.Context, connect.NewRequest(&accessv1alpha1.GetCallerIdentityRequest{})) - if err != nil { - return fmt.Errorf("failed to load caller identity for user: %w", err) + if hook == nil { + clio.Info("No access provider configured for this profile") + return nil } - grants, err := grab.AllPages(c.Context, func(ctx context.Context, nextToken *string) ([]*accessv1alpha1.Grant, *string, error) { - grants, err := grantsClient.QueryGrants(c.Context, connect.NewRequest(&accessv1alpha1.QueryGrantsRequest{ - Principal: callerID.Msg.Principal.Eid, - Target: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), - // This API needs to be updated to use specifiers, for now, fetch all active grants and check for a match on the role name - // Role: eid.New("AWS::Account", profile.AWSConfig.SSOAccountID).ToAPI(), - Status: accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE.Enum(), - })) - if err != nil { - return nil, nil, err - } - return grants.Msg.Grants, &grants.Msg.NextPageToken, nil - }) - if err != nil { - return fmt.Errorf("failed to query for active grants: %w", err) - } - - for _, grant := range grants { - if grant.Role.Name == profile.AWSConfig.SSORoleName { - durationDescription := durafmt.Parse(time.Until(grant.ExpiresAt.AsTime())).LimitFirstN(1).String() - clio.Infof("You already have an existing active grant for this profile which expires in %s, you can try assuming it now 'assume %s'", durationDescription, profile.Name) - return nil - } - } - - hook := accessrequesthook.Hook{} reason := c.String("reason") duration := c.Duration("duration") - var apiDuration *durationpb.Duration + var apiDuration *time.Duration if duration != 0 { - apiDuration = durationpb.New(duration) + apiDuration = &duration } _, _, err = hook.NoAccess(c.Context, accessrequesthook.NoAccessInput{ @@ -117,6 +84,5 @@ var latestCommand = cli.Command{ } return nil - }, } diff --git a/pkg/granted/settings/set.go b/pkg/granted/settings/set.go index 1ddeb170..c5743bf1 100644 --- a/pkg/granted/settings/set.go +++ b/pkg/granted/settings/set.go @@ -7,7 +7,6 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" - "github.com/common-fate/grab" "github.com/fwdcloudsec/granted/pkg/config" "github.com/urfave/cli/v2" ) @@ -152,7 +151,10 @@ func (f keyringFields) Set(value any) error { return nil } func (f keyringFields) Value() any { - return grab.Value(grab.Value(f.field)) + if f.field == nil || *f.field == nil { + return "" + } + return **f.field } func (f keyringFields) Kind() reflect.Kind { return reflect.String diff --git a/pkg/granted/settings/set_test.go b/pkg/granted/settings/set_test.go index 9ca7cbb2..c2f58d9b 100644 --- a/pkg/granted/settings/set_test.go +++ b/pkg/granted/settings/set_test.go @@ -4,10 +4,11 @@ import ( "slices" "testing" - "github.com/common-fate/grab" "github.com/stretchr/testify/assert" ) +func ptrString(s string) *string { return &s } + func TestFieldOptions(t *testing.T) { type input struct { A string @@ -41,7 +42,7 @@ func TestFieldOptions(t *testing.T) { D *string }{ C: "C", - D: grab.Ptr("D"), + D: ptrString("D"), }, }, want: []string{"A", "B.C", "B.D"}, diff --git a/pkg/granted/sso.go b/pkg/granted/sso.go index 31c1059f..e47d24bf 100644 --- a/pkg/granted/sso.go +++ b/pkg/granted/sso.go @@ -20,10 +20,6 @@ import ( "github.com/common-fate/awsconfigfile" "github.com/common-fate/clio" "github.com/common-fate/clio/clierr" - "github.com/common-fate/glide-cli/cmd/command" - "github.com/common-fate/glide-cli/pkg/client" - cfconfig "github.com/common-fate/glide-cli/pkg/config" - "github.com/common-fate/glide-cli/pkg/profilesource" "github.com/fwdcloudsec/granted/pkg/cfaws" grantedconfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/idclogin" @@ -349,39 +345,10 @@ var LoginCommand = cli.Command{ }, } -func getCFProfileSource(c *cli.Context, region, startURL string) (profilesource.Source, error) { - kr, err := securestorage.NewCF().Storage.Keyring() - if err != nil { - return profilesource.Source{}, err - } - - // login if the CF API isn't configured - if !cfconfig.IsConfigured() { - lf := command.LoginFlow{Keyring: kr, ForceInteractive: true} - err = lf.LoginAction(c) - if err != nil { - return profilesource.Source{}, err - } - } - - cfg, err := cfconfig.Load() - if err != nil { - return profilesource.Source{}, err - } - - cf, err := client.FromConfig(c.Context, cfg, - client.WithKeyring(kr), - client.WithLoginHint("granted login"), - ) - if err != nil { - return profilesource.Source{}, err - } - - ps := profilesource.Source{SSORegion: region, StartURL: startURL, Client: cf, DashboardURL: cfg.CurrentOrEmpty().DashboardURL} - - clio.Infof("listing available profiles from Common Fate (%s)", ps.DashboardURL) - - return ps, nil +// getCFProfileSource is deprecated - the Common Fate profile source integration +// has been removed. Use the HTTP registry instead. +func getCFProfileSource(c *cli.Context, region, startURL string) (awsconfigfile.Source, error) { + return nil, fmt.Errorf("the 'commonfate' profile source is no longer supported; use the HTTP profile registry instead (type: http)") } type AWSSSOSource struct { diff --git a/pkg/hook/accessrequesthook/accessrequesthook.go b/pkg/hook/accessrequesthook/accessrequesthook.go index 4101c8a9..058d7a0a 100644 --- a/pkg/hook/accessrequesthook/accessrequesthook.go +++ b/pkg/hook/accessrequesthook/accessrequesthook.go @@ -2,60 +2,93 @@ package accessrequesthook import ( "context" + "encoding/json" "errors" "fmt" - "net/url" "os" "strings" "time" - "connectrpc.com/connect" "github.com/AlecAivazis/survey/v2" "github.com/briandowns/spinner" - "github.com/common-fate/cli/printdiags" "github.com/common-fate/clio" - "github.com/common-fate/grab" - "github.com/fwdcloudsec/granted/pkg/cfaws" - "github.com/fwdcloudsec/granted/pkg/cfcfg" - "github.com/common-fate/sdk/config" - "github.com/common-fate/sdk/eid" - accessv1alpha1 "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1" - "github.com/common-fate/sdk/gen/commonfate/access/v1alpha1/accessv1alpha1connect" - "github.com/common-fate/sdk/loginflow" - "github.com/common-fate/sdk/service/access" "github.com/fatih/color" + "github.com/fwdcloudsec/granted/pkg/cfaws" "github.com/mattn/go-isatty" - "google.golang.org/protobuf/encoding/protojson" - durationpb "google.golang.org/protobuf/types/known/durationpb" ) -type Hook struct{} +type Hook struct { + Provider AccessProvider +} + +// NewHook creates a Hook with the given provider. +// Callers should construct the appropriate AccessProvider (e.g., httpprovider.New) +// and pass it here. Returns nil if no provider is given. +func NewHook(provider AccessProvider) *Hook { + if provider == nil { + return nil + } + return &Hook{Provider: provider} +} + +// NewHookFromProfile creates a Hook configured from the profile's provider URL. +// Returns (nil, nil) if no provider is configured on the profile. +// This is a convenience function that requires the caller to provide a +// factory function to avoid import cycles. +func NewHookFromProfile(profile *cfaws.Profile, factory func(providerURL string) (AccessProvider, error)) (*Hook, error) { + providerURL := getProviderURL(profile) + if providerURL == "" { + return nil, nil + } + provider, err := factory(providerURL) + if err != nil { + return nil, err + } + return &Hook{Provider: provider}, nil +} + +// getProviderURL reads the access provider URL from a profile's raw config. +func getProviderURL(profile *cfaws.Profile) string { + if profile == nil || profile.RawConfig == nil { + return "" + } + for _, key := range []string{"granted_access_provider_url", "common_fate_url"} { + if profile.RawConfig.HasKey(key) { + k, err := profile.RawConfig.GetKey(key) + if err != nil { + continue + } + if k.Value() != "" { + return k.Value() + } + } + } + return "" +} type NoAccessInput struct { Profile *cfaws.Profile Reason string Attachments []string - Duration *durationpb.Duration + Duration *time.Duration Confirm bool Wait bool StartTime time.Time } func (h Hook) NoAccess(ctx context.Context, input NoAccessInput) (retry bool, justActivated bool, err error) { - - cfg, err := cfcfg.Load(ctx, input.Profile) - if err != nil { - clio.Debugw("failed to load cfconfig, skipping check for active grants in a common fate deployment", "error", err) + if h.Provider == nil { + clio.Debugw("no access provider configured, skipping access request hook") return false, false, nil } - target := eid.New("AWS::Account", input.Profile.AWSConfig.SSOAccountID) + target := fmt.Sprintf("AWS::Account::%s", input.Profile.AWSConfig.SSOAccountID) role := input.Profile.AWSConfig.SSORoleName - clio.Infof("You don't currently have access to %s, checking if we can request access...\t[target=%s, role=%s, url=%s]", input.Profile.Name, target, role, cfg.AccessURL) + clio.Infof("You don't currently have access to %s, checking if we can request access...\t[target=%s, role=%s]", input.Profile.Name, target, role) - retry, _, justActivated, err = h.NoEntitlementAccess(ctx, cfg, NoEntitlementAccessInput{ - Target: target.String(), + retry, _, justActivated, err = h.NoEntitlementAccess(ctx, NoEntitlementAccessInput{ + Target: target, Role: role, Reason: input.Reason, Duration: input.Duration, @@ -73,81 +106,56 @@ type NoEntitlementAccessInput struct { Role string Reason string Attachments []string - Duration *durationpb.Duration + Duration *time.Duration Confirm bool Wait bool StartTime time.Time } -func (h Hook) NoEntitlementAccess(ctx context.Context, cfg *config.Context, input NoEntitlementAccessInput) (retry bool, result *accessv1alpha1.BatchEnsureResponse, justActivated bool, err error) { - +func (h Hook) NoEntitlementAccess(ctx context.Context, input NoEntitlementAccessInput) (retry bool, result *EnsureResponse, justActivated bool, err error) { justActivated = false - apiURL, err := url.Parse(cfg.APIURL) - if err != nil { - return false, nil, justActivated, err - } - - accessclient := access.NewFromConfig(cfg) - - req := accessv1alpha1.BatchEnsureRequest{ - Entitlements: []*accessv1alpha1.EntitlementInput{ + req := EnsureRequest{ + Entitlements: []EntitlementInput{ { - Target: &accessv1alpha1.Specifier{ - Specify: &accessv1alpha1.Specifier_Lookup{ - Lookup: input.Target, - }, - }, - Role: &accessv1alpha1.Specifier{ - Specify: &accessv1alpha1.Specifier_Lookup{ - Lookup: input.Role, - }, - }, + Target: input.Target, + Role: input.Role, Duration: input.Duration, }, }, - Justification: &accessv1alpha1.Justification{}, + Justification: Justification{}, } - hasChanges, result, err := DryRun(ctx, apiURL, accessclient, &req, false, input.Confirm) - if shouldRefreshLogin(err) { + hasChanges, result, err := h.dryRun(ctx, &req, false, input.Confirm) + if isUnauthorized(err) { clio.Debugw("prompting user login because token is expired", "error_details", err.Error()) - // NOTE(chrnorm): ideally we'll bubble up a more strongly typed error in future here, to avoid the string comparison on the error message. - - // the OAuth2.0 token is expired so we should prompt the user to log in - clio.Infof("You need to log in to Common Fate") + clio.Infof("You need to log in to your access provider") - lf := loginflow.NewFromConfig(cfg) - err = lf.Login(ctx) + err = h.Provider.Login(ctx) if err != nil { return false, nil, justActivated, err } - accessclient = access.NewFromConfig(cfg) - - // retry the Dry Run again - hasChanges, result, err = DryRun(ctx, apiURL, accessclient, &req, false, input.Confirm) + hasChanges, result, err = h.dryRun(ctx, &req, false, input.Confirm) } if err != nil { return false, nil, justActivated, err } if !hasChanges { - if result != nil && len(result.Grants) == 1 && result.Grants[0].Grant.Status == accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE { + if result != nil && len(result.Grants) == 1 && result.Grants[0].Status == GrantStatusActive { return false, result, justActivated, nil } if input.Wait { return true, result, justActivated, nil } - // shouldn't retry assuming if there aren't any proposed access changes return false, nil, justActivated, errors.New("no access changes") } - // if we get here, dry-run has passed the user has confirmed they want to proceed. req.DryRun = false if input.Reason != "" { - req.Justification.Reason = &input.Reason + req.Justification.Reason = input.Reason } else { if result.Validation != nil && result.Validation.HasReason { if !IsTerminal(os.Stdin.Fd()) { @@ -162,23 +170,16 @@ func (h Hook) NoEntitlementAccess(ctx context.Context, cfg *config.Context, inpu } withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) err = survey.AskOne(reasonPrompt, &customReason, withStdio, survey.WithValidator(survey.Required)) - if err != nil { return false, nil, justActivated, err } - req.Justification.Reason = &customReason + req.Justification.Reason = customReason } } if len(input.Attachments) > 0 { - req.Justification.Attachments = grab.Map(input.Attachments, func(t string) *accessv1alpha1.AttachmentSpecifier { - return &accessv1alpha1.AttachmentSpecifier{ - Specify: &accessv1alpha1.AttachmentSpecifier_Lookup{ - Lookup: t, - }, - } - }) + req.Justification.Attachments = input.Attachments } else { if result.Validation != nil && result.Validation.HasJiraTicket { if !IsTerminal(os.Stdin.Fd()) { @@ -193,129 +194,105 @@ func (h Hook) NoEntitlementAccess(ctx context.Context, cfg *config.Context, inpu } withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) err = survey.AskOne(reasonPrompt, &attachment, withStdio, survey.WithValidator(survey.Required)) - if err != nil { return false, nil, justActivated, err } - req.Justification.Attachments = append(req.Justification.Attachments, &accessv1alpha1.AttachmentSpecifier{ - Specify: &accessv1alpha1.AttachmentSpecifier_Lookup{ - Lookup: attachment, - }, - }) + req.Justification.Attachments = append(req.Justification.Attachments, attachment) } } - // the spinner must be started after prompting for reason, otherwise the prompt gets hidden si := spinner.New(spinner.CharSets[14], 100*time.Millisecond) si.Suffix = " ensuring access..." si.Writer = os.Stderr si.Start() - res, err := accessclient.BatchEnsure(ctx, connect.NewRequest(&req)) + res, err := h.Provider.Ensure(ctx, &req) if err != nil { si.Stop() return false, nil, justActivated, err } si.Stop() - //prints response diag messages - printdiags.Print(res.Msg.Diagnostics, nil) - clio.Debugw("BatchEnsure response", "response", res) + printDiagnostics(res.Diagnostics) - names := map[eid.EID]string{} + clio.Debugw("Ensure response", "response", debugJSON(res)) - for _, g := range res.Msg.Grants { - names[eid.New("Access::Grant", g.Grant.Id)] = g.Grant.Name - - // default is to show the original duration, except for an active request, where it gets recalculated below to the time remaining - exp := ShortDur(g.Grant.Duration.AsDuration()) + for _, g := range res.Grants { + exp := ShortDur(g.Duration) switch g.Change { - case accessv1alpha1.GrantChange_GRANT_CHANGE_ACTIVATED: + case GrantChangeActivated: _, _ = color.New(color.BgHiGreen).Fprintf(os.Stderr, "[ACTIVATED]") - _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, " %s was activated for %s: %s\n", g.Grant.Name, exp, requestURL(apiURL, g.Grant)) - + _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, " %s was activated for %s: %s\n", g.Name, exp, h.Provider.RequestURL(g.AccessRequestID)) retry = true justActivated = true - continue - case accessv1alpha1.GrantChange_GRANT_CHANGE_EXTENDED: + case GrantChangeExtended: extendedTime := "" - if g.Grant.Extension != nil { - extendedTime = ShortDur(g.Grant.Extension.ExtensionDurationSeconds.AsDuration()) + if g.Extension != nil { + extendedTime = ShortDur(g.Extension.ExtensionDuration) } _, _ = color.New(color.BgBlue).Fprintf(os.Stderr, "[EXTENDED]") - _, _ = color.New(color.FgBlue).Fprintf(os.Stderr, " %s was extended for another %s: %s\n", g.Grant.Name, extendedTime, requestURL(apiURL, g.Grant)) - _, _ = color.New(color.FgGreen).Printf(" %s will now expire in %s\n", g.Grant.Name, exp) - + _, _ = color.New(color.FgBlue).Fprintf(os.Stderr, " %s was extended for another %s: %s\n", g.Name, extendedTime, h.Provider.RequestURL(g.AccessRequestID)) + _, _ = color.New(color.FgGreen).Printf(" %s will now expire in %s\n", g.Name, exp) retry = true - continue - case accessv1alpha1.GrantChange_GRANT_CHANGE_REQUESTED: + case GrantChangeRequested: _, _ = color.New(color.BgHiYellow, color.FgBlack).Fprintf(os.Stderr, "[REQUESTED]") - _, _ = color.New(color.FgYellow).Fprintf(os.Stderr, " %s requires approval: %s\n", g.Grant.Name, requestURL(apiURL, g.Grant)) - + _, _ = color.New(color.FgYellow).Fprintf(os.Stderr, " %s requires approval: %s\n", g.Name, h.Provider.RequestURL(g.AccessRequestID)) if input.Wait { - return true, res.Msg, justActivated, nil + return true, res, justActivated, nil } - return false, nil, justActivated, errors.New("applying access was attempted but the resources requested require approval before activation") - case accessv1alpha1.GrantChange_GRANT_CHANGE_PROVISIONING_FAILED: - // shouldn't happen in the dry-run request but handle anyway - _, _ = color.New(color.FgRed).Fprintf(os.Stderr, "[ERROR] %s failed provisioning: %s\n", g.Grant.Name, requestURL(apiURL, g.Grant)) - + case GrantChangeProvisioningFailed: + _, _ = color.New(color.FgRed).Fprintf(os.Stderr, "[ERROR] %s failed provisioning: %s\n", g.Name, h.Provider.RequestURL(g.AccessRequestID)) return false, nil, justActivated, errors.New("access provisioning failed") } - switch g.Grant.Status { - case accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE: - // work out how long is remaining on the active grant - exp = ShortDur(time.Until(g.Grant.ExpiresAt.AsTime())) - _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, "[ACTIVE] %s is already active for the next %s: %s\n", g.Grant.Name, exp, requestURL(apiURL, g.Grant)) - + switch g.Status { + case GrantStatusActive: + if g.ExpiresAt != nil { + exp = ShortDur(time.Until(*g.ExpiresAt)) + } + _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, "[ACTIVE] %s is already active for the next %s: %s\n", g.Name, exp, h.Provider.RequestURL(g.AccessRequestID)) retry = true - continue - case accessv1alpha1.GrantStatus_GRANT_STATUS_PENDING: - _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[PENDING] %s is already pending: %s\n", g.Grant.Name, requestURL(apiURL, g.Grant)) + case GrantStatusPending: + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[PENDING] %s is already pending: %s\n", g.Name, h.Provider.RequestURL(g.AccessRequestID)) if input.Wait { - return true, res.Msg, justActivated, nil + return true, res, justActivated, nil } return false, nil, justActivated, errors.New("access is pending approval") - case accessv1alpha1.GrantStatus_GRANT_STATUS_CLOSED: - _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[CLOSED] %s is closed but was still returned: %s\n. This is most likely due to an error in Common Fate and should be reported to our team: support@commonfate.io.", g.Grant.Name, requestURL(apiURL, g.Grant)) - + case GrantStatusClosed: + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[CLOSED] %s is closed but was still returned: %s\n. This is most likely due to an error and should be reported.", g.Name, h.Provider.RequestURL(g.AccessRequestID)) return false, nil, justActivated, errors.New("grant was closed") default: - _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[UNSPECIFIED] %s is in an unspecified status: %s\n. This is most likely due to an error in Common Fate and should be reported to our team: support@commonfate.io.", g.Grant.Name, requestURL(apiURL, g.Grant)) + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[UNSPECIFIED] %s is in an unspecified status: %s\n. This is most likely due to an error and should be reported.", g.Name, h.Provider.RequestURL(g.AccessRequestID)) return false, nil, justActivated, errors.New("grant was in an unspecified state") } - } - printdiags.Print(res.Msg.Diagnostics, names) - - return retry, res.Msg, justActivated, nil + printDiagnostics(res.Diagnostics) + return retry, res, justActivated, nil } func (h Hook) RetryAccess(ctx context.Context, input NoAccessInput) error { - cfg, err := cfcfg.Load(ctx, input.Profile) - if err != nil { - return err + if h.Provider == nil { + return nil } - target := eid.New("AWS::Account", input.Profile.AWSConfig.SSOAccountID) + target := fmt.Sprintf("AWS::Account::%s", input.Profile.AWSConfig.SSOAccountID) role := input.Profile.AWSConfig.SSORoleName - _, err = h.RetryNoEntitlementAccess(ctx, cfg, NoEntitlementAccessInput{ - Target: target.String(), + _, err := h.RetryNoEntitlementAccess(ctx, NoEntitlementAccessInput{ + Target: target, Role: role, Reason: input.Reason, Duration: input.Duration, @@ -327,73 +304,54 @@ func (h Hook) RetryAccess(ctx context.Context, input NoAccessInput) error { return err } -func (h Hook) RetryNoEntitlementAccess(ctx context.Context, cfg *config.Context, input NoEntitlementAccessInput) (result *accessv1alpha1.BatchEnsureResponse, err error) { - - req := accessv1alpha1.BatchEnsureRequest{ - Entitlements: []*accessv1alpha1.EntitlementInput{ +func (h Hook) RetryNoEntitlementAccess(ctx context.Context, input NoEntitlementAccessInput) (result *EnsureResponse, err error) { + req := EnsureRequest{ + Entitlements: []EntitlementInput{ { - Target: &accessv1alpha1.Specifier{ - Specify: &accessv1alpha1.Specifier_Lookup{ - Lookup: input.Target, - }, - }, - Role: &accessv1alpha1.Specifier{ - Specify: &accessv1alpha1.Specifier_Lookup{ - Lookup: input.Role, - }, - }, + Target: input.Target, + Role: input.Role, Duration: input.Duration, }, }, - Justification: &accessv1alpha1.Justification{}, + Justification: Justification{}, } - accessclient := access.NewFromConfig(cfg) - res, err := accessclient.BatchEnsure(ctx, connect.NewRequest(&req)) + + res, err := h.Provider.Ensure(ctx, &req) if err != nil { return nil, err } - clio.Debugw("batch ensure response", "res", res.Msg) + clio.Debugw("ensure response", "res", debugJSON(res)) now := time.Now() elapsed := now.Sub(input.StartTime).Round(time.Second * 10) allGrantsApproved := true allGrantsActivated := true - for _, g := range res.Msg.Grants { - if g.Grant.Status == accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE { + for _, g := range res.Grants { + if g.Status == GrantStatusActive { continue } - // if grant is approved but the change is unspecified then the user is not able to automatically activate - if g.Grant.Approved && g.Change == accessv1alpha1.GrantChange_GRANT_CHANGE_UNSPECIFIED && g.Grant.ProvisioningStatus != accessv1alpha1.ProvisioningStatus_PROVISIONING_STATUS_SUCCESSFUL { - clio.Infof("Request was approved but failed to activate, you might not have permission to activate. You can try and activate the access using the Common Fate web console. [%s elapsed]", elapsed) - printdiags.Print(res.Msg.Diagnostics, nil) + if g.Approved && g.Change == GrantChangeUnspecified && g.ProvisioningStatus != "successful" { + clio.Infof("Request was approved but failed to activate, you might not have permission to activate. You can try and activate the access using the web console. [%s elapsed]", elapsed) + printDiagnostics(res.Diagnostics) } - - if !g.Grant.Approved { + if !g.Approved { clio.Infof("Waiting for request to be approved... [%s elapsed]", elapsed) allGrantsApproved = false } - if g.Grant.ActivatedAt == nil { + if g.ActivatedAt == nil { allGrantsActivated = false } - } - // Note: the current behaviour of Common Fate BatchEnsure is that it only returns the grant that you asked for event when a request already exists with multiple - // grants, if this changes in the future, we would need to fix this logic to correctly identify the grant that the user requested - // for now this will work + if !allGrantsApproved || !allGrantsActivated { - return res.Msg, errors.New("waiting on all grants to be approved and activated") + return res, errors.New("waiting on all grants to be approved and activated") } - return res.Msg, nil + return res, nil } -func requestURL(apiURL *url.URL, grant *accessv1alpha1.Grant) string { - p := apiURL.JoinPath("access", "requests", grant.AccessRequestId) - return p.String() -} - -func DryRun(ctx context.Context, apiURL *url.URL, client accessv1alpha1connect.AccessServiceClient, req *accessv1alpha1.BatchEnsureRequest, jsonOutput bool, confirm bool) (bool, *accessv1alpha1.BatchEnsureResponse, error) { +func (h Hook) dryRun(ctx context.Context, req *EnsureRequest, jsonOutput bool, confirm bool) (bool, *EnsureResponse, error) { req.DryRun = true si := spinner.New(spinner.CharSets[14], 100*time.Millisecond) @@ -401,7 +359,7 @@ func DryRun(ctx context.Context, apiURL *url.URL, client accessv1alpha1connect.A si.Writer = os.Stderr si.Start() - res, err := client.BatchEnsure(ctx, connect.NewRequest(req)) + res, err := h.Provider.Ensure(ctx, req) if err != nil { si.Stop() return false, nil, err @@ -409,78 +367,73 @@ func DryRun(ctx context.Context, apiURL *url.URL, client accessv1alpha1connect.A si.Stop() - clio.Debugw("BatchEnsure response", "response", res) + clio.Debugw("Ensure response", "response", debugJSON(res)) if jsonOutput { - resJSON, err := protojson.Marshal(res.Msg) + resJSON, err := json.Marshal(res) if err != nil { return false, nil, err } fmt.Println(string(resJSON)) - return false, nil, errors.New("exiting because --output=json was specified: use --output=text to show an interactive prompt, or use --confirm to proceed with the changes") } - names := map[eid.EID]string{} - var hasChanges bool - for _, g := range res.Msg.Grants { - names[eid.New("Access::Grant", g.Grant.Id)] = g.Grant.Name - - // default is to show the original duration, except for an active request, where it gets recalculated below to the time remaining - exp := ShortDur(g.Grant.Duration.AsDuration()) + for _, g := range res.Grants { + exp := ShortDur(g.Duration) - if g.Change > 0 { + if g.Change != GrantChangeNone && g.Change != GrantChangeUnspecified { hasChanges = true } switch g.Change { - case accessv1alpha1.GrantChange_GRANT_CHANGE_ACTIVATED: + case GrantChangeActivated: _, _ = color.New(color.BgHiGreen).Fprintf(os.Stderr, "[WILL ACTIVATE]") - _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, " %s will be activated for %s: %s\n", g.Grant.Name, exp, requestURL(apiURL, g.Grant)) + _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, " %s will be activated for %s: %s\n", g.Name, exp, h.Provider.RequestURL(g.AccessRequestID)) continue - case accessv1alpha1.GrantChange_GRANT_CHANGE_EXTENDED: + case GrantChangeExtended: extendedTime := "" - if g.Grant.Extension != nil { - extendedTime = ShortDur(g.Grant.Extension.ExtensionDurationSeconds.AsDuration()) + if g.Extension != nil { + extendedTime = ShortDur(g.Extension.ExtensionDuration) } _, _ = color.New(color.BgBlue).Printf("[WILL EXTEND]") - _, _ = color.New(color.FgBlue).Printf(" %s will be extended for another %s: %s\n", g.Grant.Name, extendedTime, requestURL(apiURL, g.Grant)) + _, _ = color.New(color.FgBlue).Printf(" %s will be extended for another %s: %s\n", g.Name, extendedTime, h.Provider.RequestURL(g.AccessRequestID)) continue - case accessv1alpha1.GrantChange_GRANT_CHANGE_REQUESTED: + case GrantChangeRequested: _, _ = color.New(color.BgHiYellow, color.FgBlack).Fprintf(os.Stderr, "[WILL REQUEST]") - _, _ = color.New(color.FgYellow).Fprintf(os.Stderr, " %s will require approval\n", g.Grant.Name) + _, _ = color.New(color.FgYellow).Fprintf(os.Stderr, " %s will require approval\n", g.Name) continue - case accessv1alpha1.GrantChange_GRANT_CHANGE_PROVISIONING_FAILED: - // shouldn't happen in the dry-run request but handle anyway - _, _ = color.New(color.FgRed).Fprintf(os.Stderr, "[ERROR] %s will fail provisioning\n", g.Grant.Name) + case GrantChangeProvisioningFailed: + _, _ = color.New(color.FgRed).Fprintf(os.Stderr, "[ERROR] %s will fail provisioning\n", g.Name) continue } - switch g.Grant.Status { - case accessv1alpha1.GrantStatus_GRANT_STATUS_ACTIVE: - exp = ShortDur(time.Until(g.Grant.ExpiresAt.AsTime())) - _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, "[ACTIVE] %s is already active for the next %s: %s\n", g.Grant.Name, exp, requestURL(apiURL, g.Grant)) + switch g.Status { + case GrantStatusActive: + if g.ExpiresAt != nil { + exp = ShortDur(time.Until(*g.ExpiresAt)) + } + _, _ = color.New(color.FgGreen).Fprintf(os.Stderr, "[ACTIVE] %s is already active for the next %s: %s\n", g.Name, exp, h.Provider.RequestURL(g.AccessRequestID)) continue - case accessv1alpha1.GrantStatus_GRANT_STATUS_PENDING: - _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[PENDING] %s is already pending: %s\n", g.Grant.Name, requestURL(apiURL, g.Grant)) + case GrantStatusPending: + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[PENDING] %s is already pending: %s\n", g.Name, h.Provider.RequestURL(g.AccessRequestID)) continue - case accessv1alpha1.GrantStatus_GRANT_STATUS_CLOSED: - _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[CLOSED] %s is closed but was still returned: %s\n. This is most likely due to an error in Common Fate and should be reported to our team: support@commonfate.io.", g.Grant.Name, requestURL(apiURL, g.Grant)) + case GrantStatusClosed: + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[CLOSED] %s is closed but was still returned: %s\n. This is most likely due to an error and should be reported.", g.Name, h.Provider.RequestURL(g.AccessRequestID)) continue } - _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[UNSPECIFIED] %s is in an unspecified status: %s\n. This is most likely due to an error in Common Fate and should be reported to our team: support@commonfate.io.", g.Grant.Name, requestURL(apiURL, g.Grant)) + _, _ = color.New(color.FgWhite).Fprintf(os.Stderr, "[UNSPECIFIED] %s is in an unspecified status: %s\n. This is most likely due to an error and should be reported.", g.Name, h.Provider.RequestURL(g.AccessRequestID)) } - printdiags.Print(res.Msg.Diagnostics, names) + printDiagnostics(res.Diagnostics) if !hasChanges { - return false, res.Msg, nil + return false, res, nil } if !confirm { @@ -503,7 +456,7 @@ func DryRun(ctx context.Context, apiURL *url.URL, client accessv1alpha1connect.A } clio.Info("Attempting to grant access...") - return confirm, res.Msg, nil + return confirm, res, nil } func IsTerminal(fd uintptr) bool { @@ -527,23 +480,39 @@ func ShortDur(d time.Duration) string { return s } -func shouldRefreshLogin(err error) bool { +func printDiagnostics(diags []Diagnostic) { + for _, d := range diags { + switch d.Level { + case "error": + clio.Errorf("[diagnostic] %s", d.Message) + case "warning": + clio.Warnf("[diagnostic] %s", d.Message) + default: + clio.Infof("[diagnostic] %s", d.Message) + } + } +} + +func isUnauthorized(err error) bool { if err == nil { return false } - if strings.Contains(err.Error(), "oauth2: token expired") { - return true - } - if strings.Contains(err.Error(), "oauth2: invalid grant") { - return true - } - // Sanity check that error message is matching correctly - if strings.Contains(err.Error(), `oauth2: "token_expired"`) { - return true - } - if strings.Contains(err.Error(), `oauth2: "invalid_grant"`) { - return true + var u Unauthorized + if errors.As(err, &u) { + return u.IsUnauthorized() } + // Fallback: check for common OAuth2 error strings + msg := err.Error() + return strings.Contains(msg, "oauth2: token expired") || + strings.Contains(msg, "oauth2: invalid grant") || + strings.Contains(msg, `oauth2: "token_expired"`) || + strings.Contains(msg, `oauth2: "invalid_grant"`) +} - return false +func debugJSON(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("(marshal error: %v)", err) + } + return string(b) } diff --git a/pkg/hook/accessrequesthook/provider.go b/pkg/hook/accessrequesthook/provider.go new file mode 100644 index 00000000..32ea807b --- /dev/null +++ b/pkg/hook/accessrequesthook/provider.go @@ -0,0 +1,90 @@ +package accessrequesthook + +import ( + "context" + "time" +) + +// AccessProvider is the interface that JIT access platforms implement. +type AccessProvider interface { + Ensure(ctx context.Context, req *EnsureRequest) (*EnsureResponse, error) + Login(ctx context.Context) error + RequestURL(accessRequestID string) string +} + +type EnsureRequest struct { + Entitlements []EntitlementInput + Justification Justification + DryRun bool +} + +type EntitlementInput struct { + Target string + Role string + Duration *time.Duration +} + +type Justification struct { + Reason string + Attachments []string +} + +type EnsureResponse struct { + Grants []GrantResult + Validation *ValidationInfo + Diagnostics []Diagnostic +} + +type GrantResult struct { + ID string + Name string + Status GrantStatus + Change GrantChange + Approved bool + Duration time.Duration + ExpiresAt *time.Time + ActivatedAt *time.Time + AccessRequestID string + ProvisioningStatus string + Extension *Extension +} + +type Extension struct { + ExtensionDuration time.Duration +} + +type ValidationInfo struct { + HasReason bool + HasJiraTicket bool +} + +type Diagnostic struct { + Level string + Message string +} + +type GrantStatus string + +const ( + GrantStatusActive GrantStatus = "active" + GrantStatusPending GrantStatus = "pending" + GrantStatusClosed GrantStatus = "closed" + GrantStatusUnspecified GrantStatus = "unspecified" +) + +// Unauthorized is an interface that errors can implement to indicate +// that the user needs to re-authenticate. +type Unauthorized interface { + IsUnauthorized() bool +} + +type GrantChange string + +const ( + GrantChangeNone GrantChange = "none" + GrantChangeActivated GrantChange = "activated" + GrantChangeExtended GrantChange = "extended" + GrantChangeRequested GrantChange = "requested" + GrantChangeProvisioningFailed GrantChange = "provisioning_failed" + GrantChangeUnspecified GrantChange = "" +) diff --git a/pkg/hook/httpprovider/httpprovider.go b/pkg/hook/httpprovider/httpprovider.go new file mode 100644 index 00000000..34812d5c --- /dev/null +++ b/pkg/hook/httpprovider/httpprovider.go @@ -0,0 +1,238 @@ +package httpprovider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/hook/accessrequesthook" + "github.com/fwdcloudsec/granted/pkg/providercfg" +) + +// HTTPProvider implements AccessProvider using REST/JSON calls. +type HTTPProvider struct { + cfg *providercfg.ProviderConfig + client *http.Client +} + +// New creates an HTTPProvider from a ProviderConfig. +func New(cfg *providercfg.ProviderConfig) *HTTPProvider { + return &HTTPProvider{ + cfg: cfg, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Ensure calls POST {apiURL}/v1/access/ensure. +func (p *HTTPProvider) Ensure(ctx context.Context, req *accessrequesthook.EnsureRequest) (*accessrequesthook.EnsureResponse, error) { + apiReq := toAPIRequest(req) + + body, err := json.Marshal(apiReq) + if err != nil { + return nil, fmt.Errorf("marshalling ensure request: %w", err) + } + + ensureURL := p.cfg.APIURL + "/v1/access/ensure" + clio.Debugw("calling ensure endpoint", "url", ensureURL, "dry_run", req.DryRun) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, ensureURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("ensure request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, &UnauthorizedError{StatusCode: resp.StatusCode} + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ensure endpoint returned HTTP %d", resp.StatusCode) + } + + var apiResp apiEnsureResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, fmt.Errorf("decoding ensure response: %w", err) + } + + return fromAPIResponse(&apiResp), nil +} + +// Login attempts OIDC authentication. For now, returns an error directing the user +// to authenticate via their browser. +func (p *HTTPProvider) Login(ctx context.Context) error { + return fmt.Errorf("please authenticate via your browser at %s", p.cfg.AccessURL) +} + +// RequestURL builds the URL for viewing an access request. +func (p *HTTPProvider) RequestURL(accessRequestID string) string { + u, err := url.Parse(p.cfg.AccessURL) + if err != nil { + return fmt.Sprintf("%s/access/requests/%s", p.cfg.AccessURL, accessRequestID) + } + return u.JoinPath("access", "requests", accessRequestID).String() +} + +// UnauthorizedError indicates the provider returned a 401, meaning the token +// is expired or invalid and the user needs to re-authenticate. +type UnauthorizedError struct { + StatusCode int +} + +func (e *UnauthorizedError) Error() string { + return fmt.Sprintf("unauthorized (HTTP %d): token expired or invalid", e.StatusCode) +} + +func (e *UnauthorizedError) IsUnauthorized() bool { + return true +} + +// IsUnauthorized checks whether an error is an UnauthorizedError. +func IsUnauthorized(err error) bool { + _, ok := err.(*UnauthorizedError) + return ok +} + +// --- API wire types --- + +type apiEnsureRequest struct { + Entitlements []apiEntitlementInput `json:"entitlements"` + Justification apiJustification `json:"justification"` + DryRun bool `json:"dry_run"` +} + +type apiEntitlementInput struct { + Target string `json:"target"` + Role string `json:"role"` + Duration string `json:"duration,omitempty"` +} + +type apiJustification struct { + Reason string `json:"reason,omitempty"` + Attachments []string `json:"attachments,omitempty"` +} + +type apiEnsureResponse struct { + Grants []apiGrantResult `json:"grants"` + Validation *apiValidation `json:"validation,omitempty"` + Diagnostics []apiDiagnostic `json:"diagnostics,omitempty"` +} + +type apiGrantResult struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Change string `json:"change"` + Approved bool `json:"approved"` + Duration string `json:"duration"` + ExpiresAt *string `json:"expires_at,omitempty"` + ActivatedAt *string `json:"activated_at,omitempty"` + AccessRequestID string `json:"access_request_id"` + ProvisioningStatus string `json:"provisioning_status,omitempty"` + Extension *apiExtension `json:"extension,omitempty"` +} + +type apiExtension struct { + ExtensionDuration string `json:"extension_duration"` +} + +type apiValidation struct { + HasReason bool `json:"has_reason"` + HasJiraTicket bool `json:"has_jira_ticket"` +} + +type apiDiagnostic struct { + Level string `json:"level"` + Message string `json:"message"` +} + +func toAPIRequest(req *accessrequesthook.EnsureRequest) *apiEnsureRequest { + apiReq := &apiEnsureRequest{ + DryRun: req.DryRun, + Justification: apiJustification{ + Reason: req.Justification.Reason, + Attachments: req.Justification.Attachments, + }, + } + + for _, e := range req.Entitlements { + ent := apiEntitlementInput{ + Target: e.Target, + Role: e.Role, + } + if e.Duration != nil { + ent.Duration = e.Duration.String() + } + apiReq.Entitlements = append(apiReq.Entitlements, ent) + } + + return apiReq +} + +func fromAPIResponse(resp *apiEnsureResponse) *accessrequesthook.EnsureResponse { + result := &accessrequesthook.EnsureResponse{} + + if resp.Validation != nil { + result.Validation = &accessrequesthook.ValidationInfo{ + HasReason: resp.Validation.HasReason, + HasJiraTicket: resp.Validation.HasJiraTicket, + } + } + + for _, d := range resp.Diagnostics { + result.Diagnostics = append(result.Diagnostics, accessrequesthook.Diagnostic{ + Level: d.Level, + Message: d.Message, + }) + } + + for _, g := range resp.Grants { + grant := accessrequesthook.GrantResult{ + ID: g.ID, + Name: g.Name, + Status: accessrequesthook.GrantStatus(g.Status), + Change: accessrequesthook.GrantChange(g.Change), + Approved: g.Approved, + AccessRequestID: g.AccessRequestID, + ProvisioningStatus: g.ProvisioningStatus, + } + + if d, err := time.ParseDuration(g.Duration); err == nil { + grant.Duration = d + } + + if g.ExpiresAt != nil { + if t, err := time.Parse(time.RFC3339, *g.ExpiresAt); err == nil { + grant.ExpiresAt = &t + } + } + + if g.ActivatedAt != nil { + if t, err := time.Parse(time.RFC3339, *g.ActivatedAt); err == nil { + grant.ActivatedAt = &t + } + } + + if g.Extension != nil { + if d, err := time.ParseDuration(g.Extension.ExtensionDuration); err == nil { + grant.Extension = &accessrequesthook.Extension{ + ExtensionDuration: d, + } + } + } + + result.Grants = append(result.Grants, grant) + } + + return result +} diff --git a/pkg/providercfg/providercfg.go b/pkg/providercfg/providercfg.go new file mode 100644 index 00000000..074cdc85 --- /dev/null +++ b/pkg/providercfg/providercfg.go @@ -0,0 +1,94 @@ +package providercfg + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/cfaws" +) + +type ProviderConfig struct { + Provider string `json:"provider"` + Version string `json:"version"` + APIURL string `json:"api_url"` + AccessURL string `json:"access_url"` + Auth AuthConfig `json:"auth"` +} + +type AuthConfig struct { + Type string `json:"type"` + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + Scopes []string `json:"scopes"` +} + +// LoadFromURL fetches the provider configuration from {providerURL}/granted/config.json. +func LoadFromURL(ctx context.Context, providerURL string) (*ProviderConfig, error) { + u, err := url.Parse(providerURL) + if err != nil { + return nil, fmt.Errorf("invalid provider URL (%s): %w", providerURL, err) + } + + configURL := u.JoinPath("granted", "config.json").String() + clio.Debugw("loading provider config", "url", configURL) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, configURL, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching provider config from %s: %w", configURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("provider config returned HTTP %d from %s", resp.StatusCode, configURL) + } + + var cfg ProviderConfig + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + return nil, fmt.Errorf("decoding provider config from %s: %w", configURL, err) + } + + return &cfg, nil +} + +// GetProviderURL reads the access provider URL from a profile's raw config. +// It checks granted_access_provider_url first, then common_fate_url as a legacy alias. +// Returns an empty string if neither key is set. +func GetProviderURL(profile *cfaws.Profile) string { + if profile == nil || profile.RawConfig == nil { + return "" + } + + for _, key := range []string{"granted_access_provider_url", "common_fate_url"} { + if profile.RawConfig.HasKey(key) { + k, err := profile.RawConfig.GetKey(key) + if err != nil { + clio.Debugw("error reading profile key", "key", key, "error", err) + continue + } + if k.Value() != "" { + return k.Value() + } + } + } + + return "" +} + +// GenerateRequestURL builds a URL to view an access request in the provider UI. +func GenerateRequestURL(accessURL string, requestID string) (string, error) { + u, err := url.Parse(accessURL) + if err != nil { + return "", err + } + p := u.JoinPath("access", "requests", requestID) + return p.String(), nil +} From b4f14f0a42999d32765f21b74652668c59c24258 Mon Sep 17 00:00:00 2001 From: BillyJBryant <3013565+billyjbryant@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:55:41 -0700 Subject: [PATCH 4/7] feat: OIDC auth flow for provider authentication Implement full OAuth Authorization Code + PKCE flow for authenticating Granted CLI to JIT access providers (JITSudo, etc.). - pkg/securestorage/provider_token_storage.go: keyring-based token storage - pkg/idclogin/provider_login.go: OIDC auth code + PKCE against generic IdP - pkg/hook/httpprovider/: Bearer token injection + auto re-auth on 401 - pkg/granted/registry/httpregistry/: Bearer token on profile sync - pkg/granted/auth/: working login/logout CLI commands - granted auth login : triggers OIDC flow, stores token - granted auth logout : clears stored token Token lifecycle: 1. granted auth login -> OIDC flow -> token stored in keyring 2. assume -> hook reads token from keyring -> sends Bearer header 3. On 401 -> prompts re-login (interactive) or returns error (non-interactive) Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/assume/assume.go | 2 +- pkg/config/config.go | 1 + pkg/granted/auth/auth.go | 52 ++- pkg/granted/registry/add.go | 10 +- .../registry/httpregistry/httpregistry.go | 76 ++++- pkg/granted/registry/registry.go | 5 +- pkg/granted/request/request.go | 2 +- pkg/hook/httpprovider/httpprovider.go | 111 ++++++- pkg/idclogin/provider_login.go | 312 ++++++++++++++++++ pkg/securestorage/provider_token_storage.go | 47 +++ 10 files changed, 586 insertions(+), 32 deletions(-) create mode 100644 pkg/idclogin/provider_login.go create mode 100644 pkg/securestorage/provider_token_storage.go diff --git a/pkg/assume/assume.go b/pkg/assume/assume.go index 7c7a7dd5..2b28b899 100644 --- a/pkg/assume/assume.go +++ b/pkg/assume/assume.go @@ -739,7 +739,7 @@ func newHTTPProvider(providerURL string) (accessrequesthook.AccessProvider, erro if err != nil { return nil, err } - return httpprovider.New(cfg), nil + return httpprovider.New(cfg, providerURL, ""), nil } func filterMultiToken(filterValue string, optValue string, optIndex int) bool { diff --git a/pkg/config/config.go b/pkg/config/config.go index a4464e52..f7dfbec9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -120,6 +120,7 @@ type Registry struct { PrefixDuplicateProfiles bool `toml:"prefixDuplicateProfiles,omitempty"` PrefixAllProfiles bool `toml:"prefixAllProfiles,omitempty"` Type string `toml:"type,omitempty"` + TenantID string `toml:"tenantID,omitempty"` } type AWSSSOConfiguration struct { diff --git a/pkg/granted/auth/auth.go b/pkg/granted/auth/auth.go index 74e722af..25e8bd90 100644 --- a/pkg/granted/auth/auth.go +++ b/pkg/granted/auth/auth.go @@ -2,9 +2,12 @@ package auth import ( "fmt" + "time" "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/idclogin" "github.com/fwdcloudsec/granted/pkg/providercfg" + "github.com/fwdcloudsec/granted/pkg/securestorage" "github.com/urfave/cli/v2" ) @@ -38,10 +41,33 @@ var loginCommand = cli.Command{ return fmt.Errorf("failed to load provider config from %s: %w", providerURL, err) } - // TODO: implement OIDC login flow using cfg.Auth - clio.Infof("Provider config loaded from %s (auth type: %s, issuer: %s)", providerURL, cfg.Auth.Type, cfg.Auth.Issuer) - clio.Warn("OIDC login flow is not yet implemented. Please authenticate via your browser.") + if cfg.Auth.Type != "oidc" { + return fmt.Errorf("unsupported auth type '%s' for provider at %s (expected 'oidc')", cfg.Auth.Type, providerURL) + } + + output, err := idclogin.ProviderLogin(c.Context, idclogin.ProviderLoginInput{ + IssuerURL: cfg.Auth.Issuer, + ClientID: cfg.Auth.ClientID, + Scopes: cfg.Auth.Scopes, + }) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + tokenStorage := securestorage.NewProviderTokenStorage() + err = tokenStorage.StoreToken(providerURL, securestorage.ProviderToken{ + AccessToken: output.AccessToken, + RefreshToken: output.RefreshToken, + IDToken: output.IDToken, + TokenType: output.TokenType, + Expiry: time.Now().Add(time.Duration(output.ExpiresIn) * time.Second), + ProviderURL: providerURL, + }) + if err != nil { + return fmt.Errorf("failed to store token: %w", err) + } + clio.Successf("Successfully authenticated to %s (%s)", cfg.Provider, providerURL) return nil }, } @@ -49,9 +75,25 @@ var loginCommand = cli.Command{ var logoutCommand = cli.Command{ Name: "logout", Usage: "Log out of an access provider", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "url", Usage: "The access provider URL to log out from"}, + }, Action: func(c *cli.Context) error { - // TODO: implement logout (clear stored tokens) - clio.Info("Logout is not yet implemented") + providerURL := c.String("url") + if providerURL == "" { + providerURL = c.Args().First() + } + if providerURL == "" { + return fmt.Errorf("please provide a provider URL, e.g. 'granted auth logout https://provider.example.com'") + } + + tokenStorage := securestorage.NewProviderTokenStorage() + err := tokenStorage.ClearToken(providerURL) + if err != nil { + return fmt.Errorf("failed to clear token: %w", err) + } + + clio.Successf("Logged out from %s", providerURL) return nil }, } diff --git a/pkg/granted/registry/add.go b/pkg/granted/registry/add.go index ef42e9fd..50e3f5da 100644 --- a/pkg/granted/registry/add.go +++ b/pkg/granted/registry/add.go @@ -35,7 +35,8 @@ var AddCommand = cli.Command{ &cli.BoolFlag{Name: "prefix-duplicate-profiles", Aliases: []string{"pdp"}, Usage: "Provide this flag if you want to append registry name to duplicate profiles"}, &cli.BoolFlag{Name: "write-on-sync-failure", Aliases: []string{"wosf"}, Usage: "Always overwrite AWS config, even if sync fails (DEPRECATED)"}, &cli.StringSliceFlag{Name: "required-key", Aliases: []string{"r", "requiredKey"}, Usage: "Used to bypass the prompt or override user specific values"}, - &cli.StringFlag{Name: "type", Value: "git", Usage: "specify the type of granted registry source you want to set up. Default: git"}}, + &cli.StringFlag{Name: "type", Value: "git", Usage: "specify the type of granted registry source you want to set up. Default: git"}, + &cli.StringFlag{Name: "tenant-id", Usage: "For HTTP registries: the tenant ID for multi-tenant providers"}}, ArgsUsage: "--name --url --type ", Action: func(c *cli.Context) error { @@ -60,6 +61,7 @@ var AddCommand = cli.Command{ requiredKey := c.StringSlice("required-key") priority := c.Int("priority") registryType := c.String("type") + tenantID := c.String("tenant-id") if registryType != "git" && registryType != "http" { return fmt.Errorf("invalid registry type provided: %s. must be 'git' or 'http'", c.String("type")) @@ -83,6 +85,7 @@ var AddCommand = cli.Command{ PrefixDuplicateProfiles: prefixDuplicateProfiles, PrefixAllProfiles: prefixAllProfiles, Type: registryType, + TenantID: tenantID, } if registryType == "git" { @@ -166,8 +169,9 @@ var AddCommand = cli.Command{ } else { registry := httpregistry.New(httpregistry.Opts{ - Name: name, - URL: URL, + Name: name, + URL: URL, + TenantID: tenantID, }) if err != nil { diff --git a/pkg/granted/registry/httpregistry/httpregistry.go b/pkg/granted/registry/httpregistry/httpregistry.go index e650be94..8f04628b 100644 --- a/pkg/granted/registry/httpregistry/httpregistry.go +++ b/pkg/granted/registry/httpregistry/httpregistry.go @@ -9,19 +9,23 @@ import ( "time" "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/idclogin" "github.com/fwdcloudsec/granted/pkg/providercfg" + "github.com/fwdcloudsec/granted/pkg/securestorage" "gopkg.in/ini.v1" ) type Registry struct { - opts Opts - mu sync.Mutex - cfg *providercfg.ProviderConfig + opts Opts + mu sync.Mutex + cfg *providercfg.ProviderConfig + tokenStorage securestorage.ProviderTokenStorage } type Opts struct { - Name string - URL string + Name string + URL string + TenantID string } // getConfig lazily loads the provider configuration. @@ -46,8 +50,52 @@ func (r *Registry) getConfig(interactive bool) (*providercfg.ProviderConfig, err return r.cfg, nil } +// getToken returns a valid Bearer token for the provider, triggering login if interactive. +func (r *Registry) getToken(ctx context.Context, cfg *providercfg.ProviderConfig, interactive bool) (string, error) { + if cfg.Auth.Type != "oidc" { + return "", nil + } + + token := r.tokenStorage.GetValidToken(r.opts.URL) + if token != nil { + return token.AccessToken, nil + } + + if !interactive { + return "", fmt.Errorf("no valid token for provider %s. Run 'granted auth login --url %s' to authenticate", r.opts.URL, r.opts.URL) + } + + output, err := idclogin.ProviderLogin(ctx, idclogin.ProviderLoginInput{ + IssuerURL: cfg.Auth.Issuer, + ClientID: cfg.Auth.ClientID, + Scopes: cfg.Auth.Scopes, + }) + if err != nil { + return "", err + } + + providerToken := securestorage.ProviderToken{ + AccessToken: output.AccessToken, + RefreshToken: output.RefreshToken, + IDToken: output.IDToken, + TokenType: output.TokenType, + Expiry: time.Now().Add(time.Duration(output.ExpiresIn) * time.Second), + ProviderURL: r.opts.URL, + TenantID: r.opts.TenantID, + } + + if err := r.tokenStorage.StoreToken(r.opts.URL, providerToken); err != nil { + clio.Warnf("failed to store provider token: %s", err) + } + + return output.AccessToken, nil +} + func New(opts Opts) *Registry { - return &Registry{opts: opts} + return &Registry{ + opts: opts, + tokenStorage: securestorage.NewProviderTokenStorage(), + } } type listProfilesResponse struct { @@ -56,8 +104,8 @@ type listProfilesResponse struct { } type profileEntry struct { - Name string `json:"name"` - Attributes []profileKeyVal `json:"attributes"` + Name string `json:"name"` + Attributes []profileKeyVal `json:"attributes"` } type profileKeyVal struct { @@ -73,6 +121,11 @@ func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File client := &http.Client{Timeout: 30 * time.Second} + accessToken, err := r.getToken(ctx, cfg, interactive) + if err != nil { + return nil, err + } + var allProfiles []profileEntry var pageToken string @@ -88,6 +141,13 @@ func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File } req.Header.Set("Accept", "application/json") + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + if r.opts.TenantID != "" { + req.Header.Set("X-Tenant-ID", r.opts.TenantID) + } + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetching profiles from %s: %w", listURL, err) diff --git a/pkg/granted/registry/registry.go b/pkg/granted/registry/registry.go index 01d21b4d..fbe3a140 100644 --- a/pkg/granted/registry/registry.go +++ b/pkg/granted/registry/registry.go @@ -51,8 +51,9 @@ func GetProfileRegistries(interactive bool) ([]loadedRegistry, error) { }) } else { reg := httpregistry.New(httpregistry.Opts{ - Name: r.Name, - URL: r.URL, + Name: r.Name, + URL: r.URL, + TenantID: r.TenantID, }) registries = append(registries, loadedRegistry{ Config: r, diff --git a/pkg/granted/request/request.go b/pkg/granted/request/request.go index fa5ec732..89dcb8f3 100644 --- a/pkg/granted/request/request.go +++ b/pkg/granted/request/request.go @@ -27,7 +27,7 @@ func newHTTPProvider(providerURL string) (accessrequesthook.AccessProvider, erro if err != nil { return nil, err } - return httpprovider.New(cfg), nil + return httpprovider.New(cfg, providerURL, ""), nil } var latestCommand = cli.Command{ diff --git a/pkg/hook/httpprovider/httpprovider.go b/pkg/hook/httpprovider/httpprovider.go index 34812d5c..ac261147 100644 --- a/pkg/hook/httpprovider/httpprovider.go +++ b/pkg/hook/httpprovider/httpprovider.go @@ -11,20 +11,55 @@ import ( "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/pkg/hook/accessrequesthook" + "github.com/fwdcloudsec/granted/pkg/idclogin" "github.com/fwdcloudsec/granted/pkg/providercfg" + "github.com/fwdcloudsec/granted/pkg/securestorage" ) // HTTPProvider implements AccessProvider using REST/JSON calls. type HTTPProvider struct { - cfg *providercfg.ProviderConfig - client *http.Client + cfg *providercfg.ProviderConfig + client *http.Client + tokenStorage securestorage.ProviderTokenStorage + providerURL string + tenantID string } // New creates an HTTPProvider from a ProviderConfig. -func New(cfg *providercfg.ProviderConfig) *HTTPProvider { +func New(cfg *providercfg.ProviderConfig, providerURL string, tenantID string) *HTTPProvider { return &HTTPProvider{ - cfg: cfg, - client: &http.Client{Timeout: 30 * time.Second}, + cfg: cfg, + client: &http.Client{Timeout: 30 * time.Second}, + tokenStorage: securestorage.NewProviderTokenStorage(), + providerURL: providerURL, + tenantID: tenantID, + } +} + +// getToken returns a valid Bearer token, triggering login if needed. +func (p *HTTPProvider) getToken(ctx context.Context, interactive bool) (string, error) { + token := p.tokenStorage.GetValidToken(p.providerURL) + if token != nil { + return token.AccessToken, nil + } + if !interactive { + return "", fmt.Errorf("no valid token for provider %s. Run 'granted auth login --url %s' to authenticate", p.providerURL, p.providerURL) + } + if err := p.Login(ctx); err != nil { + return "", err + } + token = p.tokenStorage.GetValidToken(p.providerURL) + if token == nil { + return "", fmt.Errorf("login succeeded but no token was stored") + } + return token.AccessToken, nil +} + +// setAuthHeaders adds Authorization and optional X-Tenant-ID to a request. +func (p *HTTPProvider) setAuthHeaders(req *http.Request, token string) { + req.Header.Set("Authorization", "Bearer "+token) + if p.tenantID != "" { + req.Header.Set("X-Tenant-ID", p.tenantID) } } @@ -40,11 +75,17 @@ func (p *HTTPProvider) Ensure(ctx context.Context, req *accessrequesthook.Ensure ensureURL := p.cfg.APIURL + "/v1/access/ensure" clio.Debugw("calling ensure endpoint", "url", ensureURL, "dry_run", req.DryRun) + token, err := p.getToken(ctx, true) + if err != nil { + return nil, fmt.Errorf("getting auth token: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, ensureURL, bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/json") + p.setAuthHeaders(httpReq, token) resp, err := p.client.Do(httpReq) if err != nil { @@ -53,7 +94,31 @@ func (p *HTTPProvider) Ensure(ctx context.Context, req *accessrequesthook.Ensure defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { - return nil, &UnauthorizedError{StatusCode: resp.StatusCode} + clio.Debug("received 401, attempting re-authentication") + if err := p.Login(ctx); err != nil { + return nil, fmt.Errorf("re-authentication failed: %w", err) + } + newToken, err := p.getToken(ctx, false) + if err != nil { + return nil, &UnauthorizedError{StatusCode: resp.StatusCode} + } + + httpReq, err = http.NewRequestWithContext(ctx, http.MethodPost, ensureURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + p.setAuthHeaders(httpReq, newToken) + + resp, err = p.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("ensure retry request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, &UnauthorizedError{StatusCode: resp.StatusCode} + } } if resp.StatusCode != http.StatusOK { @@ -68,10 +133,32 @@ func (p *HTTPProvider) Ensure(ctx context.Context, req *accessrequesthook.Ensure return fromAPIResponse(&apiResp), nil } -// Login attempts OIDC authentication. For now, returns an error directing the user -// to authenticate via their browser. +// Login performs OIDC authentication against the provider's identity provider. func (p *HTTPProvider) Login(ctx context.Context) error { - return fmt.Errorf("please authenticate via your browser at %s", p.cfg.AccessURL) + if p.cfg.Auth.Type != "oidc" { + return fmt.Errorf("unsupported auth type: %s (expected 'oidc')", p.cfg.Auth.Type) + } + + output, err := idclogin.ProviderLogin(ctx, idclogin.ProviderLoginInput{ + IssuerURL: p.cfg.Auth.Issuer, + ClientID: p.cfg.Auth.ClientID, + Scopes: p.cfg.Auth.Scopes, + }) + if err != nil { + return err + } + + token := securestorage.ProviderToken{ + AccessToken: output.AccessToken, + RefreshToken: output.RefreshToken, + IDToken: output.IDToken, + TokenType: output.TokenType, + Expiry: time.Now().Add(time.Duration(output.ExpiresIn) * time.Second), + ProviderURL: p.providerURL, + TenantID: p.tenantID, + } + + return p.tokenStorage.StoreToken(p.providerURL, token) } // RequestURL builds the URL for viewing an access request. @@ -123,9 +210,9 @@ type apiJustification struct { } type apiEnsureResponse struct { - Grants []apiGrantResult `json:"grants"` - Validation *apiValidation `json:"validation,omitempty"` - Diagnostics []apiDiagnostic `json:"diagnostics,omitempty"` + Grants []apiGrantResult `json:"grants"` + Validation *apiValidation `json:"validation,omitempty"` + Diagnostics []apiDiagnostic `json:"diagnostics,omitempty"` } type apiGrantResult struct { diff --git a/pkg/idclogin/provider_login.go b/pkg/idclogin/provider_login.go new file mode 100644 index 00000000..f48f0cf9 --- /dev/null +++ b/pkg/idclogin/provider_login.go @@ -0,0 +1,312 @@ +package idclogin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/common-fate/clio" + "github.com/google/uuid" +) + +type ProviderLoginInput struct { + IssuerURL string + ClientID string + Scopes []string + BrowserProfile string +} + +type ProviderLoginOutput struct { + AccessToken string + RefreshToken string + IDToken string + TokenType string + ExpiresIn int +} + +// OIDCDiscovery holds endpoints from a standard OpenID Connect discovery document. +type OIDCDiscovery struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` +} + +// DiscoverOIDC fetches the OpenID Connect discovery document from the issuer's +// well-known configuration endpoint. +func DiscoverOIDC(ctx context.Context, issuerURL string) (*OIDCDiscovery, error) { + discoveryURL := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration" + clio.Debugw("fetching OIDC discovery document", "url", discoveryURL) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + if err != nil { + return nil, fmt.Errorf("building OIDC discovery request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching OIDC discovery from %s: %w", discoveryURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OIDC discovery returned HTTP %d from %s", resp.StatusCode, discoveryURL) + } + + var discovery OIDCDiscovery + if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil { + return nil, fmt.Errorf("decoding OIDC discovery from %s: %w", discoveryURL, err) + } + + if discovery.AuthorizationEndpoint == "" { + return nil, fmt.Errorf("OIDC discovery from %s missing authorization_endpoint", discoveryURL) + } + if discovery.TokenEndpoint == "" { + return nil, fmt.Errorf("OIDC discovery from %s missing token_endpoint", discoveryURL) + } + + return &discovery, nil +} + +// ProviderLogin performs an OAuth Authorization Code + PKCE flow against a +// generic OIDC provider (not AWS SSO specific). It opens the user's browser, +// waits for the authorization callback, and exchanges the code for tokens. +func ProviderLogin(ctx context.Context, input ProviderLoginInput) (*ProviderLoginOutput, error) { + discovery, err := DiscoverOIDC(ctx, input.IssuerURL) + if err != nil { + return nil, err + } + + callbackResult := make(chan providerCallbackResult, 1) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to start local OAuth callback server: %w", err) + } + + port := listener.Addr().(*net.TCPAddr).Port + callbackURL := fmt.Sprintf("http://127.0.0.1:%d/callback", port) + + codeVerifier, err := generateCodeVerifier() + if err != nil { + _ = listener.Close() + return nil, fmt.Errorf("failed to generate PKCE code verifier: %w", err) + } + codeChallenge := computeCodeChallenge(codeVerifier) + + state := uuid.New().String() + + srv := &http.Server{ + Handler: newProviderCallbackHandler(state, callbackResult), + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 30 * time.Second, + } + go func() { + if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + clio.Debugf("OAuth callback server error: %s", err) + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + authorizeURL, err := buildProviderAuthorizeURL(discovery.AuthorizationEndpoint, input.ClientID, callbackURL, state, codeChallenge, input.Scopes) + if err != nil { + return nil, fmt.Errorf("failed to build authorize URL: %w", err) + } + + if err := OpenBrowserWithFallbackMessage(authorizeURL, input.BrowserProfile); err != nil { + return nil, err + } + + clio.Info("Awaiting authentication in the browser") + clio.Info("You will be prompted to authenticate and approve access") + + var result providerCallbackResult + select { + case result = <-callbackResult: + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(authorizationCallbackTimeout): + return nil, errors.New("timed out waiting for authorization callback") + } + + if result.err != nil { + return nil, fmt.Errorf("authorization failed: %w", result.err) + } + + output, err := exchangeCodeForToken(ctx, discovery.TokenEndpoint, tokenExchangeInput{ + Code: result.code, + ClientID: input.ClientID, + RedirectURI: callbackURL, + CodeVerifier: codeVerifier, + }) + if err != nil { + return nil, err + } + + return output, nil +} + +type providerCallbackResult struct { + code string + err error +} + +const providerCallbackSuccessHTML = ` + +Granted - Authentication Successful + +
+

Authentication Successful

+

You have successfully authenticated with your access provider.

+

You can close this window and return to your terminal.

+
+ +` + +func newProviderCallbackHandler(expectedState string, result chan<- providerCallbackResult) http.Handler { + var once sync.Once + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var handled bool + once.Do(func() { + handled = true + query := r.URL.Query() + + if errParam := query.Get("error"); errParam != "" { + errDesc := query.Get("error_description") + writeErrorPage(w, errParam, errDesc) + result <- providerCallbackResult{err: fmt.Errorf("%s: %s", errParam, errDesc)} + return + } + + code := query.Get("code") + st := query.Get("state") + + if st != expectedState { + writeErrorPage(w, "state_mismatch", "The state parameter did not match. This may indicate a CSRF attack.") + result <- providerCallbackResult{err: errors.New("OAuth state parameter mismatch")} + return + } + + if code == "" { + writeErrorPage(w, "missing_code", "No authorization code was received.") + result <- providerCallbackResult{err: errors.New("no authorization code received")} + return + } + + setSecurityHeaders(w) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(providerCallbackSuccessHTML)) + result <- providerCallbackResult{code: code} + }) + + if !handled { + http.Error(w, "Authorization already processed", http.StatusConflict) + } + }) + return mux +} + +func buildProviderAuthorizeURL(authorizationEndpoint, clientID, redirectURI, state, codeChallenge string, scopes []string) (string, error) { + u, err := url.Parse(authorizationEndpoint) + if err != nil { + return "", err + } + + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", clientID) + q.Set("redirect_uri", redirectURI) + q.Set("state", state) + q.Set("code_challenge", codeChallenge) + q.Set("code_challenge_method", "S256") + q.Set("scope", strings.Join(scopes, " ")) + u.RawQuery = q.Encode() + + return u.String(), nil +} + +type tokenExchangeInput struct { + Code string + ClientID string + RedirectURI string + CodeVerifier string +} + +type tokenExchangeResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +func exchangeCodeForToken(ctx context.Context, tokenEndpoint string, input tokenExchangeInput) (*ProviderLoginOutput, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {input.Code}, + "client_id": {input.ClientID}, + "redirect_uri": {input.RedirectURI}, + "code_verifier": {input.CodeVerifier}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("building token exchange request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("token exchange request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading token exchange response: %w", err) + } + + var tokenResp tokenExchangeResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("decoding token exchange response: %w", err) + } + + if tokenResp.Error != "" { + return nil, fmt.Errorf("token exchange error: %s: %s", tokenResp.Error, tokenResp.ErrorDesc) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token endpoint returned HTTP %d", resp.StatusCode) + } + + if tokenResp.AccessToken == "" { + return nil, errors.New("token exchange returned empty access_token") + } + + return &ProviderLoginOutput{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + IDToken: tokenResp.IDToken, + TokenType: tokenResp.TokenType, + ExpiresIn: tokenResp.ExpiresIn, + }, nil +} diff --git a/pkg/securestorage/provider_token_storage.go b/pkg/securestorage/provider_token_storage.go new file mode 100644 index 00000000..ac4e0888 --- /dev/null +++ b/pkg/securestorage/provider_token_storage.go @@ -0,0 +1,47 @@ +package securestorage + +import ( + "time" +) + +type ProviderTokenStorage struct { + SecureStorage SecureStorage +} + +type ProviderToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` + TokenType string `json:"token_type"` + Expiry time.Time `json:"expiry"` + ProviderURL string `json:"provider_url"` + TenantID string `json:"tenant_id,omitempty"` +} + +func NewProviderTokenStorage() ProviderTokenStorage { + return ProviderTokenStorage{ + SecureStorage: SecureStorage{StorageSuffix: "granted-provider-tokens"}, + } +} + +// GetValidToken returns a stored token if it exists and is not expired. +// Returns nil if no valid token exists. +func (s *ProviderTokenStorage) GetValidToken(providerURL string) *ProviderToken { + var token ProviderToken + err := s.SecureStorage.Retrieve(providerURL, &token) + if err != nil { + return nil + } + if time.Now().After(token.Expiry) { + return nil + } + return &token +} + +func (s *ProviderTokenStorage) StoreToken(providerURL string, token ProviderToken) error { + return s.SecureStorage.Store(providerURL, token) +} + +func (s *ProviderTokenStorage) ClearToken(providerURL string) error { + return s.SecureStorage.Clear(providerURL) +} From 3483d5757b6e80b93a57461b47d844b764ce4c8d Mon Sep 17 00:00:00 2001 From: BillyJBryant <3013565+billyjbryant@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:10:14 -0700 Subject: [PATCH 5/7] feat: auto-populate tenant_id from provider config When a provider returns tenant_id in /granted/config.json (single-tenant deployments), use it automatically so users don't need --tenant-id. Falls back to explicit tenant_id if configured. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/granted/registry/httpregistry/httpregistry.go | 8 ++++++-- pkg/hook/httpprovider/httpprovider.go | 5 +++++ pkg/providercfg/providercfg.go | 1 + 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/pkg/granted/registry/httpregistry/httpregistry.go b/pkg/granted/registry/httpregistry/httpregistry.go index 8f04628b..294c7807 100644 --- a/pkg/granted/registry/httpregistry/httpregistry.go +++ b/pkg/granted/registry/httpregistry/httpregistry.go @@ -144,8 +144,12 @@ func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File if accessToken != "" { req.Header.Set("Authorization", "Bearer "+accessToken) } - if r.opts.TenantID != "" { - req.Header.Set("X-Tenant-ID", r.opts.TenantID) + tenantID := r.opts.TenantID + if tenantID == "" { + tenantID = cfg.TenantID + } + if tenantID != "" { + req.Header.Set("X-Tenant-ID", tenantID) } resp, err := client.Do(req) diff --git a/pkg/hook/httpprovider/httpprovider.go b/pkg/hook/httpprovider/httpprovider.go index ac261147..918743da 100644 --- a/pkg/hook/httpprovider/httpprovider.go +++ b/pkg/hook/httpprovider/httpprovider.go @@ -26,7 +26,12 @@ type HTTPProvider struct { } // New creates an HTTPProvider from a ProviderConfig. +// If tenantID is empty, falls back to the tenant_id from the provider config +// (auto-populated by single-tenant providers). func New(cfg *providercfg.ProviderConfig, providerURL string, tenantID string) *HTTPProvider { + if tenantID == "" { + tenantID = cfg.TenantID + } return &HTTPProvider{ cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}, diff --git a/pkg/providercfg/providercfg.go b/pkg/providercfg/providercfg.go index 074cdc85..c46d6943 100644 --- a/pkg/providercfg/providercfg.go +++ b/pkg/providercfg/providercfg.go @@ -16,6 +16,7 @@ type ProviderConfig struct { Version string `json:"version"` APIURL string `json:"api_url"` AccessURL string `json:"access_url"` + TenantID string `json:"tenant_id,omitempty"` Auth AuthConfig `json:"auth"` } From c70ed5f793a67859ee2f9ad506e77dacb75730fc Mon Sep 17 00:00:00 2001 From: BillyJBryant <3013565+billyjbryant@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:17:01 -0700 Subject: [PATCH 6/7] fix: handle resp.Body.Close() error returns for errcheck lint Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/idclogin/provider_login.go | 4 ++-- pkg/providercfg/providercfg.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/idclogin/provider_login.go b/pkg/idclogin/provider_login.go index f48f0cf9..a5a78aac 100644 --- a/pkg/idclogin/provider_login.go +++ b/pkg/idclogin/provider_login.go @@ -54,7 +54,7 @@ func DiscoverOIDC(ctx context.Context, issuerURL string) (*OIDCDiscovery, error) if err != nil { return nil, fmt.Errorf("fetching OIDC discovery from %s: %w", discoveryURL, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("OIDC discovery returned HTTP %d from %s", resp.StatusCode, discoveryURL) @@ -278,7 +278,7 @@ func exchangeCodeForToken(ctx context.Context, tokenEndpoint string, input token if err != nil { return nil, fmt.Errorf("token exchange request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/pkg/providercfg/providercfg.go b/pkg/providercfg/providercfg.go index c46d6943..a93561bf 100644 --- a/pkg/providercfg/providercfg.go +++ b/pkg/providercfg/providercfg.go @@ -46,7 +46,7 @@ func LoadFromURL(ctx context.Context, providerURL string) (*ProviderConfig, erro if err != nil { return nil, fmt.Errorf("fetching provider config from %s: %w", configURL, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("provider config returned HTTP %d from %s", resp.StatusCode, configURL) From 3aadf59c9bafa0429ff507e62ec60a2f30bbd1c2 Mon Sep 17 00:00:00 2001 From: BillyJBryant <3013565+billyjbryant@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:48:47 -0700 Subject: [PATCH 7/7] refactor: vendor awsconfigfile, relocate httpregistry, wire up registry-based SSO sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendor github.com/common-fate/awsconfigfile into pkg/awsconfigfile since we needed to update struct fields and ini tags to use provider-agnostic naming — modifying the external dependency in place was not an option. Key changes: - Vendor awsconfigfile: rename CommonFateURL -> ProviderURL, CommonFateGeneratedFrom -> GeneratedFrom, ini tag now granted_generated_from (with backwards-compat read of common_fate_generated_from for pruning) - Relocate httpregistry from pkg/granted/registry/httpregistry to pkg/httpregistry — pkg/granted/ is CLI wiring, not library code - Wire --source flag in sso generate/populate to accept named profile registries from config, replacing the removed commonfate source - Fix remaining errcheck lint violations in httpprovider - Clean up stale commonfate references in autosync, accessrequest, assume, and sso flag descriptions Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 3 +- go.sum | 2 - pkg/accessrequest/role.go | 2 +- pkg/assume/assume.go | 4 +- pkg/autosync/registry_config.go | 6 +- pkg/awsconfigfile/awscfg.go | 163 ++++++++++++++++++ pkg/awsconfigfile/config_path.go | 17 ++ pkg/awsconfigfile/generator.go | 97 +++++++++++ pkg/granted/registry/add.go | 2 +- pkg/granted/registry/registry.go | 2 +- pkg/granted/sso.go | 51 ++++-- pkg/hook/httpprovider/httpprovider.go | 4 +- .../httpregistry/httpregistry.go | 47 ++++- 13 files changed, 364 insertions(+), 36 deletions(-) create mode 100644 pkg/awsconfigfile/awscfg.go create mode 100644 pkg/awsconfigfile/config_path.go create mode 100644 pkg/awsconfigfile/generator.go rename pkg/{granted/registry => }/httpregistry/httpregistry.go (78%) diff --git a/go.mod b/go.mod index 62ab1b89..69cc33be 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( ) require ( + github.com/Masterminds/sprig/v3 v3.2.3 github.com/alessio/shellescape v1.4.2 github.com/briandowns/spinner v1.23.0 github.com/common-fate/clio v1.2.3 @@ -32,7 +33,6 @@ require ( require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect - github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -69,7 +69,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/iam v1.28.7 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 // indirect github.com/aws/smithy-go v1.24.1 - github.com/common-fate/awsconfigfile v0.10.0 github.com/common-fate/useragent v0.1.0 github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/danieljoos/wincred v1.1.2 // indirect diff --git a/go.sum b/go.sum index a33232be..6579687e 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,6 @@ github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= -github.com/common-fate/awsconfigfile v0.10.0 h1:9W0JTeO0d3jNLw3Ps9U7IJwLYp4D9zcipq/sqNEWJOg= -github.com/common-fate/awsconfigfile v0.10.0/go.mod h1:znstvN26aO+KUwmdjwZ+WcmitZ7heEJb5iFdCPokAO8= github.com/common-fate/clio v1.2.3 h1:hHwUYZjn66qGYDpgANl0EB/92hyi/Jsnd07qB09rvn4= github.com/common-fate/clio v1.2.3/go.mod h1:NkozaS15SA+6Y9zb+82eIj1i41aWShorTqA01GKQ7A8= github.com/common-fate/updatecheck v0.3.5 h1:UGIKMnYwuHjbhhCaisLz1pNPg8Z1nXEoWcfqT+4LkAg= diff --git a/pkg/accessrequest/role.go b/pkg/accessrequest/role.go index 01ad8d7f..fbaae170 100644 --- a/pkg/accessrequest/role.go +++ b/pkg/accessrequest/role.go @@ -26,7 +26,7 @@ func (r Role) URL(dashboardURL string) string { } u.Path = "access" q := u.Query() - q.Add("type", "commonfate/aws-sso") + q.Add("type", "aws-sso") q.Add("permissionSetArn.label", r.Role) q.Add("accountId", r.Account) u.RawQuery = q.Encode() diff --git a/pkg/assume/assume.go b/pkg/assume/assume.go index 2b28b899..d04bddc7 100644 --- a/pkg/assume/assume.go +++ b/pkg/assume/assume.go @@ -18,7 +18,7 @@ import ( "github.com/alessio/shellescape" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" - "github.com/common-fate/awsconfigfile" + "github.com/fwdcloudsec/granted/pkg/awsconfigfile" "github.com/common-fate/clio" "github.com/common-fate/clio/ansi" "github.com/common-fate/clio/clierr" @@ -154,7 +154,7 @@ func AssumeCommand(c *cli.Context) error { AccountID: profile.AWSConfig.SSOAccountID, AccountName: profile.AWSConfig.SSOAccountID, RoleName: profile.AWSConfig.SSORoleName, - GeneratedFrom: "commonfate", + GeneratedFrom: "granted-provider", }, }, }) diff --git a/pkg/autosync/registry_config.go b/pkg/autosync/registry_config.go index 76c3bac9..feb6269c 100644 --- a/pkg/autosync/registry_config.go +++ b/pkg/autosync/registry_config.go @@ -19,7 +19,7 @@ type RegistrySyncConfig struct { LastCheckForSync time.Weekday `json:"lastCheckForSync"` } -// return the absolute path of commonfate/registry-sync file. +// return the absolute path of the registry-sync file. func (rc RegistrySyncConfig) Path() string { return path.Join(rc.dir, FILENAME) } @@ -59,10 +59,10 @@ func loadRegistryConfig() (rc RegistrySyncConfig, ok bool) { return } - rc.dir = path.Join(cd, "commonfate") + rc.dir = path.Join(cd, "granted") err = os.MkdirAll(rc.dir, os.ModePerm) if err != nil { - clio.Debug("error creating commonfate config dir: %s", err.Error()) + clio.Debug("error creating granted config dir: %s", err.Error()) return } diff --git a/pkg/awsconfigfile/awscfg.go b/pkg/awsconfigfile/awscfg.go new file mode 100644 index 00000000..05bb38ad --- /dev/null +++ b/pkg/awsconfigfile/awscfg.go @@ -0,0 +1,163 @@ +// Package awsconfigfile contains logic to template ~/.aws/config files +// based on profile sources (AWS SSO, HTTP registries, etc). +// +// Vendored from github.com/common-fate/awsconfigfile v0.10.0 and updated +// to use provider-agnostic naming. +package awsconfigfile + +import ( + "bytes" + "sort" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + "gopkg.in/ini.v1" +) + +type SSOProfile struct { + SSOStartURL string + SSORegion string + + Region string + AccountID string + AccountName string + RoleName string + ProviderURL string + // GeneratedFrom is the source that the profile was created from, + // such as 'aws-sso' or a named HTTP registry. + GeneratedFrom string +} + +// ToIni converts a profile to a struct with `ini` tags ready to be +// written to an ini config file. +// +// If noCredentialProcess is true, the struct will contain sso_ parameters. +// Otherwise it will contain granted_sso parameters for use with the +// Granted credential process. +func (p SSOProfile) ToIni(profileName string, noCredentialProcess bool) any { + if noCredentialProcess { + return ®ularProfile{ + SSOStartURL: p.SSOStartURL, + SSORegion: p.SSORegion, + SSOAccountID: p.AccountID, + SSORoleName: p.RoleName, + GeneratedFrom: p.GeneratedFrom, + Region: p.Region, + } + } + + credProcess := "granted credential-process --profile " + profileName + + if p.ProviderURL != "" { + credProcess += " --url " + p.ProviderURL + } + + return &credentialProcessProfile{ + SSOStartURL: p.SSOStartURL, + SSORegion: p.SSORegion, + SSOAccountID: p.AccountID, + SSORoleName: p.RoleName, + CredProcess: credProcess, + GeneratedFrom: p.GeneratedFrom, + Region: p.Region, + } +} + +type MergeOpts struct { + Config *ini.File + Prefix string + Profiles []SSOProfile + SectionNameTemplate string + NoCredentialProcess bool + // PruneStartURLs is a slice of AWS SSO start URLs which profiles are being + // generated for. Existing profiles with these start URLs will be removed if + // they aren't found in the Profiles field. + PruneStartURLs []string +} + +func Merge(opts MergeOpts) error { + if opts.SectionNameTemplate == "" { + opts.SectionNameTemplate = "{{ .AccountName }}/{{ .RoleName }}" + } + + sort.SliceStable(opts.Profiles, func(i, j int) bool { + combinedNameI := opts.Profiles[i].AccountName + "/" + opts.Profiles[i].RoleName + combinedNameJ := opts.Profiles[j].AccountName + "/" + opts.Profiles[j].RoleName + return combinedNameI < combinedNameJ + }) + + funcMap := sprig.TxtFuncMap() + sectionNameTempl, err := template.New("").Funcs(funcMap).Parse(opts.SectionNameTemplate) + if err != nil { + return err + } + + // remove any config sections that have 'common_fate_generated_from' as a key + // (legacy) or 'granted_generated_from' (current) + for _, sec := range opts.Config.Sections() { + var startURL string + + if sec.HasKey("granted_sso_start_url") { + startURL = sec.Key("granted_sso_start_url").String() + } else if sec.HasKey("sso_start_url") { + startURL = sec.Key("sso_start_url").String() + } + + for _, pruneURL := range opts.PruneStartURLs { + isGenerated := sec.HasKey("granted_generated_from") || sec.HasKey("common_fate_generated_from") + + if isGenerated && startURL == pruneURL { + opts.Config.DeleteSection(sec.Name()) + } + } + } + + for _, ssoProfile := range opts.Profiles { + ssoProfile.AccountName = normalizeAccountName(ssoProfile.AccountName) + sectionNameBuffer := bytes.NewBufferString("") + err := sectionNameTempl.Execute(sectionNameBuffer, ssoProfile) + if err != nil { + return err + } + profileName := opts.Prefix + sectionNameBuffer.String() + sectionName := "profile " + profileName + + opts.Config.DeleteSection(sectionName) + section, err := opts.Config.NewSection(sectionName) + if err != nil { + return err + } + + entry := ssoProfile.ToIni(profileName, opts.NoCredentialProcess) + err = section.ReflectFrom(entry) + if err != nil { + return err + } + } + + return nil +} + +type credentialProcessProfile struct { + SSOStartURL string `ini:"granted_sso_start_url"` + SSORegion string `ini:"granted_sso_region"` + SSOAccountID string `ini:"granted_sso_account_id"` + SSORoleName string `ini:"granted_sso_role_name"` + GeneratedFrom string `ini:"granted_generated_from"` + CredProcess string `ini:"credential_process"` + Region string `ini:"region,omitempty"` +} + +type regularProfile struct { + SSOStartURL string `ini:"sso_start_url"` + SSORegion string `ini:"sso_region"` + SSOAccountID string `ini:"sso_account_id"` + GeneratedFrom string `ini:"granted_generated_from"` + SSORoleName string `ini:"sso_role_name"` + Region string `ini:"region,omitempty"` +} + +func normalizeAccountName(accountName string) string { + return strings.ReplaceAll(accountName, " ", "-") +} diff --git a/pkg/awsconfigfile/config_path.go b/pkg/awsconfigfile/config_path.go new file mode 100644 index 00000000..e0f091db --- /dev/null +++ b/pkg/awsconfigfile/config_path.go @@ -0,0 +1,17 @@ +package awsconfigfile + +import ( + "os" + "path/filepath" +) + +// DefaultSharedConfigFilename returns the SDK's default file path for +// the shared config file (~/.aws/config). +func DefaultSharedConfigFilename() string { + return filepath.Join(userHomeDir(), ".aws", "config") +} + +func userHomeDir() string { + homedir, _ := os.UserHomeDir() + return homedir +} diff --git a/pkg/awsconfigfile/generator.go b/pkg/awsconfigfile/generator.go new file mode 100644 index 00000000..b3df890d --- /dev/null +++ b/pkg/awsconfigfile/generator.go @@ -0,0 +1,97 @@ +package awsconfigfile + +import ( + "context" + "fmt" + "regexp" + "strings" + "sync" + + "golang.org/x/sync/errgroup" + "gopkg.in/ini.v1" +) + +// Source returns AWS profiles to be combined into an AWS config file. +type Source interface { + GetProfiles(ctx context.Context) ([]SSOProfile, error) +} + +// Generator generates AWS profiles for ~/.aws/config. +// It reads profiles from sources and merges them with +// an existing ini config file. +type Generator struct { + Sources []Source + Config *ini.File + NoCredentialProcess bool + ProfileNameTemplate string + Prefix string + // PruneStartURLs is a slice of AWS SSO start URLs which profiles are being + // generated for. Existing profiles with these start URLs will be removed if + // they aren't found in the Profiles field. + PruneStartURLs []string +} + +// AddSource adds a new source to load profiles from to the generator. +func (g *Generator) AddSource(source Source) { + g.Sources = append(g.Sources, source) +} + +const profileSectionIllegalChars = ` \][;'"` + +// regular expression that matches on the characters \][;'" including whitespace, +// but does not match anything between {{ }} so it does not check inside go templates +var profileSectionIllegalCharsRegex = regexp.MustCompile(`(?s)((?:^|[^\{])[\s\][;'"]|[\][;'"][\s]*(?:$|[^\}]))`) +var matchGoTemplateSection = regexp.MustCompile(`\{\{[\s\S]*?\}\}`) + +var DefaultProfileNameTemplate = "{{ .AccountName }}/{{ .RoleName }}" + +// Generate AWS profiles and merge them with the existing config. +func (g *Generator) Generate(ctx context.Context) error { + var eg errgroup.Group + var mu sync.Mutex + var profiles []SSOProfile + + if strings.ContainsAny(g.Prefix, profileSectionIllegalChars) { + return fmt.Errorf("profile prefix must not contain any of these illegal characters (%s)", profileSectionIllegalChars) + } + + if g.ProfileNameTemplate == "" { + g.ProfileNameTemplate = DefaultProfileNameTemplate + } + + if g.ProfileNameTemplate != DefaultProfileNameTemplate { + cleaned := matchGoTemplateSection.ReplaceAllString(g.ProfileNameTemplate, "") + if profileSectionIllegalCharsRegex.MatchString(cleaned) { + return fmt.Errorf("profile template must not contain any of these illegal characters (%s)", profileSectionIllegalChars) + } + } + + for _, s := range g.Sources { + scopy := s + eg.Go(func() error { + got, err := scopy.GetProfiles(ctx) + if err != nil { + return err + } + mu.Lock() + defer mu.Unlock() + profiles = append(profiles, got...) + return nil + }) + } + + err := eg.Wait() + if err != nil { + return err + } + + err = Merge(MergeOpts{ + Config: g.Config, + SectionNameTemplate: g.ProfileNameTemplate, + Profiles: profiles, + NoCredentialProcess: g.NoCredentialProcess, + Prefix: g.Prefix, + PruneStartURLs: g.PruneStartURLs, + }) + return err +} diff --git a/pkg/granted/registry/add.go b/pkg/granted/registry/add.go index 50e3f5da..943d2b33 100644 --- a/pkg/granted/registry/add.go +++ b/pkg/granted/registry/add.go @@ -8,7 +8,7 @@ import ( "github.com/common-fate/clio" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/granted/awsmerge" - "github.com/fwdcloudsec/granted/pkg/granted/registry/httpregistry" + "github.com/fwdcloudsec/granted/pkg/httpregistry" "github.com/fwdcloudsec/granted/pkg/granted/registry/gitregistry" "github.com/fwdcloudsec/granted/pkg/testable" diff --git a/pkg/granted/registry/registry.go b/pkg/granted/registry/registry.go index fbe3a140..c47d3815 100644 --- a/pkg/granted/registry/registry.go +++ b/pkg/granted/registry/registry.go @@ -5,7 +5,7 @@ import ( "sort" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" - "github.com/fwdcloudsec/granted/pkg/granted/registry/httpregistry" + "github.com/fwdcloudsec/granted/pkg/httpregistry" "github.com/fwdcloudsec/granted/pkg/granted/registry/gitregistry" "gopkg.in/ini.v1" ) diff --git a/pkg/granted/sso.go b/pkg/granted/sso.go index e47d24bf..e8f4b562 100644 --- a/pkg/granted/sso.go +++ b/pkg/granted/sso.go @@ -17,11 +17,12 @@ import ( "github.com/aws/aws-sdk-go-v2/aws/retry" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sso" - "github.com/common-fate/awsconfigfile" "github.com/common-fate/clio" "github.com/common-fate/clio/clierr" + "github.com/fwdcloudsec/granted/pkg/awsconfigfile" "github.com/fwdcloudsec/granted/pkg/cfaws" grantedconfig "github.com/fwdcloudsec/granted/pkg/config" + "github.com/fwdcloudsec/granted/pkg/httpregistry" "github.com/fwdcloudsec/granted/pkg/idclogin" "github.com/fwdcloudsec/granted/pkg/securestorage" "github.com/fwdcloudsec/granted/pkg/testable" @@ -53,7 +54,7 @@ var GenerateCommand = cli.Command{ &cli.StringFlag{Name: "config", Usage: "Specify the SSO config section in the Granted config file ([SSO.name])", Value: "default"}, &cli.StringFlag{Name: "prefix", Usage: "Specify a prefix for all generated profile names"}, &cli.StringFlag{Name: "sso-region", Usage: "Specify the SSO region"}, - &cli.StringSliceFlag{Name: "source", Usage: "The sources to load AWS profiles from (valid values are: 'aws-sso', 'commonfate')", Value: cli.NewStringSlice("aws-sso")}, + &cli.StringSliceFlag{Name: "source", Usage: "The sources to load AWS profiles from ('aws-sso' or a named profile registry)", Value: cli.NewStringSlice("aws-sso")}, &cli.BoolFlag{Name: "no-credential-process", Usage: "Generate profiles without the Granted credential-process integration"}, &cli.StringFlag{Name: "profile-template", Usage: "Specify profile name template", Value: awsconfigfile.DefaultProfileNameTemplate}, &cli.StringFlag{Name: "sso-browser-profile", Usage: "Use a pre-existing profile in your browser for SSO login", EnvVars: []string{"GRANTED_SSO_BROWSER_PROFILE"}}, @@ -108,14 +109,12 @@ var GenerateCommand = cli.Command{ switch s { case "aws-sso": g.AddSource(AWSSSOSource{SSORegion: ssoRegion, StartURL: startURL, SSOBrowserProfile: ssoBrowserProfile, UseDeviceCode: c.Bool("use-device-code")}) - case "commonfate", "common-fate", "cf": - ps, err := getCFProfileSource(c, ssoRegion, startURL) + default: + reg, err := registrySourceByName(cfg, s) if err != nil { return err } - g.AddSource(ps) - default: - return fmt.Errorf("unknown profile source %s: allowed sources are aws-sso, commonfate", s) + g.AddSource(reg) } } @@ -142,8 +141,8 @@ var PopulateCommand = cli.Command{ &cli.StringFlag{Name: "prefix", Usage: "Specify a prefix for all generated profile names"}, &cli.StringFlag{Name: "sso-region", Usage: "Specify the SSO region"}, &cli.StringSliceFlag{Name: "sso-scope", Usage: "Specify the SSO scopes"}, - &cli.StringSliceFlag{Name: "source", Usage: "The sources to load AWS profiles from", Value: cli.NewStringSlice("aws-sso")}, - &cli.BoolFlag{Name: "prune", Usage: "Remove any generated profiles with the 'common_fate_generated_from' key which no longer exist"}, + &cli.StringSliceFlag{Name: "source", Usage: "The sources to load AWS profiles from ('aws-sso' or a named profile registry)", Value: cli.NewStringSlice("aws-sso")}, + &cli.BoolFlag{Name: "prune", Usage: "Remove any generated profiles which no longer exist in the source"}, &cli.StringFlag{Name: "profile-template", Usage: "Specify profile name template", Value: awsconfigfile.DefaultProfileNameTemplate}, &cli.BoolFlag{Name: "no-credential-process", Usage: "Generate profiles without the Granted credential-process integration"}, &cli.StringFlag{Name: "sso-browser-profile", Usage: "Use a pre-existing profile in your browser for SSO login", EnvVars: []string{"GRANTED_SSO_BROWSER_PROFILE"}}, @@ -229,14 +228,12 @@ var PopulateCommand = cli.Command{ switch s { case "aws-sso": g.AddSource(AWSSSOSource{SSORegion: ssoRegion, StartURL: startURL, SSOScopes: c.StringSlice("sso-scope"), SSOBrowserProfile: ssoBrowserProfile, UseDeviceCode: c.Bool("use-device-code")}) - case "commonfate", "common-fate", "cf": - ps, err := getCFProfileSource(c, ssoRegion, startURL) + default: + reg, err := registrySourceByName(cfg, s) if err != nil { return err } - g.AddSource(ps) - default: - return fmt.Errorf("unknown profile source %s: allowed sources are aws-sso, commonfate", s) + g.AddSource(reg) } } err = g.Generate(ctx) @@ -345,10 +342,28 @@ var LoginCommand = cli.Command{ }, } -// getCFProfileSource is deprecated - the Common Fate profile source integration -// has been removed. Use the HTTP registry instead. -func getCFProfileSource(c *cli.Context, region, startURL string) (awsconfigfile.Source, error) { - return nil, fmt.Errorf("the 'commonfate' profile source is no longer supported; use the HTTP profile registry instead (type: http)") +// registrySourceByName looks up a named profile registry from the Granted +// config and returns it as an awsconfigfile.Source for use with sso generate/populate. +func registrySourceByName(cfg *grantedconfig.Config, name string) (awsconfigfile.Source, error) { + var registryNames []string + for _, r := range cfg.ProfileRegistry.Registries { + registryNames = append(registryNames, r.Name) + if r.Name == name { + if r.Type != "http" && r.Type != "" { + return nil, fmt.Errorf("profile registry %q is type %q, only 'http' registries can be used as an SSO profile source", name, r.Type) + } + return httpregistry.New(httpregistry.Opts{ + Name: r.Name, + URL: r.URL, + TenantID: r.TenantID, + }), nil + } + } + + if len(registryNames) == 0 { + return nil, fmt.Errorf("unknown profile source %q: no profile registries are configured.\nAdd one with: granted registry add --name --url --type http", name) + } + return nil, fmt.Errorf("unknown profile source %q: no profile registry found with that name.\nAvailable registries: %s", name, fmt.Sprintf("%v", registryNames)) } type AWSSSOSource struct { diff --git a/pkg/hook/httpprovider/httpprovider.go b/pkg/hook/httpprovider/httpprovider.go index 918743da..491ea770 100644 --- a/pkg/hook/httpprovider/httpprovider.go +++ b/pkg/hook/httpprovider/httpprovider.go @@ -96,7 +96,7 @@ func (p *HTTPProvider) Ensure(ctx context.Context, req *accessrequesthook.Ensure if err != nil { return nil, fmt.Errorf("ensure request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusUnauthorized { clio.Debug("received 401, attempting re-authentication") @@ -119,7 +119,7 @@ func (p *HTTPProvider) Ensure(ctx context.Context, req *accessrequesthook.Ensure if err != nil { return nil, fmt.Errorf("ensure retry request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusUnauthorized { return nil, &UnauthorizedError{StatusCode: resp.StatusCode} diff --git a/pkg/granted/registry/httpregistry/httpregistry.go b/pkg/httpregistry/httpregistry.go similarity index 78% rename from pkg/granted/registry/httpregistry/httpregistry.go rename to pkg/httpregistry/httpregistry.go index 294c7807..085e385d 100644 --- a/pkg/granted/registry/httpregistry/httpregistry.go +++ b/pkg/httpregistry/httpregistry.go @@ -9,6 +9,7 @@ import ( "time" "github.com/common-fate/clio" + "github.com/fwdcloudsec/granted/pkg/awsconfigfile" "github.com/fwdcloudsec/granted/pkg/idclogin" "github.com/fwdcloudsec/granted/pkg/providercfg" "github.com/fwdcloudsec/granted/pkg/securestorage" @@ -113,7 +114,8 @@ type profileKeyVal struct { Value string `json:"value"` } -func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File, error) { +// fetchProfiles retrieves raw profile entries from the HTTP registry API. +func (r *Registry) fetchProfiles(ctx context.Context, interactive bool) ([]profileEntry, error) { cfg, err := r.getConfig(interactive) if err != nil { return nil, err @@ -158,16 +160,16 @@ func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File } if resp.StatusCode != http.StatusOK { - resp.Body.Close() + _ = resp.Body.Close() return nil, fmt.Errorf("profile registry returned HTTP %d from %s", resp.StatusCode, listURL) } var listResp listProfilesResponse if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { - resp.Body.Close() + _ = resp.Body.Close() return nil, fmt.Errorf("decoding profile list from %s: %w", listURL, err) } - resp.Body.Close() + _ = resp.Body.Close() allProfiles = append(allProfiles, listResp.Profiles...) @@ -177,6 +179,15 @@ func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File pageToken = listResp.NextPageToken } + return allProfiles, nil +} + +func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File, error) { + allProfiles, err := r.fetchProfiles(ctx, interactive) + if err != nil { + return nil, err + } + result := ini.Empty() for _, profile := range allProfiles { @@ -195,3 +206,31 @@ func (r *Registry) AWSProfiles(ctx context.Context, interactive bool) (*ini.File return result, nil } + +// GetProfiles implements awsconfigfile.Source, allowing an HTTP registry +// to be used as a profile source in `granted sso generate` and `granted sso populate`. +func (r *Registry) GetProfiles(ctx context.Context) ([]awsconfigfile.SSOProfile, error) { + entries, err := r.fetchProfiles(ctx, true) + if err != nil { + return nil, err + } + + var profiles []awsconfigfile.SSOProfile + for _, entry := range entries { + attrs := make(map[string]string, len(entry.Attributes)) + for _, kv := range entry.Attributes { + attrs[kv.Key] = kv.Value + } + + profiles = append(profiles, awsconfigfile.SSOProfile{ + SSOStartURL: attrs["sso_start_url"], + SSORegion: attrs["sso_region"], + AccountID: attrs["sso_account_id"], + AccountName: attrs["account_name"], + RoleName: attrs["sso_role_name"], + GeneratedFrom: r.opts.Name, + }) + } + + return profiles, nil +}