diff --git a/cmd/policy/main.go b/cmd/policy/main.go index f941473116eae58d78626bab031c280dd8e1846f..f718301b8d3e1deddbf3cd285d2fe6fb1b3a03b3 100644 --- a/cmd/policy/main.go +++ b/cmd/policy/main.go @@ -87,6 +87,7 @@ func main() { taskFuncs := regofunc.NewTaskFuncs(cfg.Task.Addr, httpClient) ocmFuncs := regofunc.NewOcmFuncs(cfg.OCM.Addr, httpClient) signerFuncs := regofunc.NewSignerFuncs(cfg.Signer.Addr, httpClient) + didTransformerFuncs := regofunc.NewDIDTransformerFuncs() regofunc.Register("cacheGet", rego.Function3(cacheFuncs.CacheGetFunc())) regofunc.Register("cacheSet", rego.Function4(cacheFuncs.CacheSetFunc())) regofunc.Register("didResolve", rego.Function1(didResolverFuncs.ResolveFunc())) @@ -99,6 +100,8 @@ func main() { regofunc.Register("verifyProof", rego.Function1(signerFuncs.VerifyProof())) regofunc.Register("ocmLoginProofInvitation", rego.Function2(ocmFuncs.GetLoginProofInvitation())) regofunc.Register("ocmLoginProofResult", rego.Function1(ocmFuncs.GetLoginProofResult())) + regofunc.Register("didToURL", rego.Function1(didTransformerFuncs.ToURLFunc())) + regofunc.Register("urlToDID", rego.Function1(didTransformerFuncs.FromURLFunc())) } // subscribe the cache for policy data changes diff --git a/internal/regofunc/did_transformer.go b/internal/regofunc/did_transformer.go new file mode 100644 index 0000000000000000000000000000000000000000..dbd390726627998388edfd2c4d21c8af1d508b3a --- /dev/null +++ b/internal/regofunc/did_transformer.go @@ -0,0 +1,145 @@ +package regofunc + +import ( + "fmt" + "net/url" + "strings" + + "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/rego" + "github.com/open-policy-agent/opa/types" + "gitlab.com/gaia-x/data-infrastructure-federation-services/tsa/golib/errors" +) + +const ( + didSeparator = ":" + urlSeparator = "/" + defaultUrlPath = ".well-known" +) + +type DIDTransformerFuncs struct{} + +type DID struct { + scheme string // scheme is always "did" + method string // method is the specific did method - "web" in this case + path string // path is the unique URI assigned by the DID method +} + +func NewDIDTransformerFuncs() *DIDTransformerFuncs { + return &DIDTransformerFuncs{} +} + +func (dt *DIDTransformerFuncs) ToURLFunc() (*rego.Function, rego.Builtin1) { + return ®o.Function{ + Name: "url_from_did", + Decl: types.NewFunction(types.Args(types.S), types.A), + Memoize: true, + }, + func(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { + var did string + + if err := ast.As(a.Value, &did); err != nil { + return nil, fmt.Errorf("invalid DID: %s", err) + } + if did == "" { + return nil, errors.New("DID cannot be empty") + } + + u, err := dt.didToURL(did) + if err != nil { + return nil, err + } + + return ast.StringTerm(u.String()), nil + } +} + +func (dt *DIDTransformerFuncs) FromURLFunc() (*rego.Function, rego.Builtin1) { + return ®o.Function{ + Name: "did_from_url", + Decl: types.NewFunction(types.Args(types.S), types.A), + Memoize: true, + }, + func(bctx rego.BuiltinContext, a *ast.Term) (*ast.Term, error) { + var u string + + if err := ast.As(a.Value, &u); err != nil { + return nil, fmt.Errorf("invalid URL: %s", err) + } + if u == "" { + return nil, errors.New("URL cannot be empty") + } + uri, err := url.Parse(u) + if err != nil { + return nil, errors.New("cannot parse URL") + } + if uri.Host == "" || uri.Scheme != "https" { + return nil, errors.New("invalid URL for did:web method") + } + + did := dt.urlToDID(uri) + + return ast.StringTerm(did.String()), nil + } +} + +// didToURL transforms a valid DID, created by the "did:web" Method Specification, to a URL. +// Documentation can be found here: https://w3c-ccg.github.io/did-method-web/ +func (dt *DIDTransformerFuncs) didToURL(DID string) (*url.URL, error) { + ss := strings.Split(DID, didSeparator) + if len(ss) < 3 { + return nil, errors.New("invalid DID, host is not found") + } + if ss[0] != "did" || ss[1] != "web" { + return nil, errors.New("invalid DID, method is unknown") + } + + path := defaultUrlPath + if len(ss) > 3 { + path = "" + for i := 3; i < len(ss); i++ { + path = path + urlSeparator + ss[i] + } + } + path = path + urlSeparator + "did.json" + + host, err := url.PathUnescape(ss[2]) + if err != nil { + return nil, errors.New("failed to url decode host from DID") + } + + return &url.URL{ + Scheme: "https", + Host: host, + Path: path, + }, nil +} + +// urlToDID transforms a valid URL to a DID created following the "did:web" Method Specification. +// Documentation can be found here: https://w3c-ccg.github.io/did-method-web/ +func (dt *DIDTransformerFuncs) urlToDID(uri *url.URL) *DID { + p := strings.TrimRight(uri.Path, "did.json") + sp := strings.Split(p, urlSeparator) + + path := url.QueryEscape(uri.Host) + for _, v := range sp { + if v == defaultUrlPath { + break + } + if v == "" { + continue + } + path = path + didSeparator + url.QueryEscape(v) + } + + return &DID{ + scheme: "did", + method: "web", + path: strings.Trim(path, didSeparator), + } +} + +// String returns a string representation of this DID. +func (d *DID) String() string { + return fmt.Sprintf("%s:%s:%s", d.scheme, d.method, d.path) +} diff --git a/internal/regofunc/did_transformer_test.go b/internal/regofunc/did_transformer_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4fed242c83653999796f87c7823d246f86039d16 --- /dev/null +++ b/internal/regofunc/did_transformer_test.go @@ -0,0 +1,142 @@ +package regofunc_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/open-policy-agent/opa/rego" + "github.com/stretchr/testify/assert" + "gitlab.com/gaia-x/data-infrastructure-federation-services/tsa/policy/internal/regofunc" +) + +func TestToURLFunc(t *testing.T) { + tests := []struct { + // test input + name string + regoQuery string + // expected result + res string + errText string + }{ + { + name: "DID is empty", + regoQuery: `url_from_did("")`, + errText: "DID cannot be empty", + }, + { + name: "invalid DID", + regoQuery: `url_from_did("invalid-did")`, + errText: "invalid DID, host is not found", + }, + { + name: "invalid DID Method", + regoQuery: `url_from_did("did:sov:123456qwerty")`, + errText: "invalid DID, method is unknown", + }, + { + name: "transformation success with DID containing domain only", + regoQuery: `url_from_did("did:web:w3c-ccg.github.io")`, + res: "\"https://w3c-ccg.github.io/.well-known/did.json\"", + }, + { + name: "transformation success with DID containing domain and path", + regoQuery: `url_from_did("did:web:w3c-ccg.github.io:user:alice")`, + res: "\"https://w3c-ccg.github.io/user/alice/did.json\"", + }, + { + name: "transformation success with DID containing network port", + regoQuery: `url_from_did("did:web:example.com%3A3000:user:alice")`, + res: "\"https://example.com:3000/user/alice/did.json\"", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + DIDTransformerFuncs := regofunc.NewDIDTransformerFuncs() + + r := rego.New( + rego.Query(test.regoQuery), + rego.Function1(DIDTransformerFuncs.ToURLFunc()), + rego.StrictBuiltinErrors(true), + ) + resultSet, err := r.Eval(context.Background()) + + if err == nil { + resultBytes, err := json.Marshal(resultSet[0].Expressions[0].Value) + assert.NoError(t, err) + assert.Equal(t, test.res, string(resultBytes)) + } else { + assert.ErrorContains(t, err, test.errText) + } + }) + } +} + +func TestFromURLFunc(t *testing.T) { + tests := []struct { + // test input + name string + regoQuery string + // expected result + res string + errText string + }{ + { + name: "empty URL", + regoQuery: `did_from_url("")`, + errText: "URL cannot be empty", + }, + { + name: "URL containing special characters", + regoQuery: `did_from_url("example.com\nH1234")`, + errText: "cannot parse URL", + }, + { + name: "URL does not contain secure protocol (https)", + regoQuery: `did_from_url("example.com")`, + errText: "invalid URL for did:web method", + }, + { + name: "URL does not contain valid domain", + regoQuery: `did_from_url("https://")`, + errText: "invalid URL for did:web method", + }, + { + name: "transformation success with URL containing domain only", + regoQuery: `did_from_url("https://w3c-ccg.github.io/.well-known/did.json")`, + res: "\"did:web:w3c-ccg.github.io\"", + }, + { + name: "transformation success with URL containing domain with path", + regoQuery: `did_from_url("https://w3c-ccg.github.io/user/alice/did.json")`, + res: "\"did:web:w3c-ccg.github.io:user:alice\"", + }, + { + name: "transformation success with URL containing network port", + regoQuery: `did_from_url("https://example.com:3000/user/alice/did.json")`, + res: "\"did:web:example.com%3A3000:user:alice\"", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + DIDTransformerFuncs := regofunc.NewDIDTransformerFuncs() + + r := rego.New( + rego.Query(test.regoQuery), + rego.Function1(DIDTransformerFuncs.FromURLFunc()), + rego.StrictBuiltinErrors(true), + ) + resultSet, err := r.Eval(context.Background()) + + if err == nil { + resultBytes, err := json.Marshal(resultSet[0].Expressions[0].Value) + assert.NoError(t, err) + assert.Equal(t, test.res, string(resultBytes)) + } else { + assert.ErrorContains(t, err, test.errText) + } + }) + } +}