diff --git a/cmd/policy/main.go b/cmd/policy/main.go index f941473116eae58d78626bab031c280dd8e1846f..b3c9ff27db77622eb6dabdc4b4054f1349d3acab 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.NewDIDWebFuncs() 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.DIDToURLFunc())) + regofunc.Register("urlToDID", rego.Function1(didTransformerFuncs.URLToDIDFunc())) } // subscribe the cache for policy data changes diff --git a/internal/regofunc/did_web.go b/internal/regofunc/did_web.go new file mode 100644 index 0000000000000000000000000000000000000000..7b403b615a3ecd08e88c24452d44fa92f42a7dbb --- /dev/null +++ b/internal/regofunc/did_web.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 DIDWebFuncs 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 NewDIDWebFuncs() *DIDWebFuncs { + return &DIDWebFuncs{} +} + +func (dw *DIDWebFuncs) DIDToURLFunc() (*rego.Function, rego.Builtin1) { + return ®o.Function{ + Name: "did_to_url", + 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 := dw.didToURL(did) + if err != nil { + return nil, err + } + + return ast.StringTerm(u.String()), nil + } +} + +func (dw *DIDWebFuncs) URLToDIDFunc() (*rego.Function, rego.Builtin1) { + return ®o.Function{ + Name: "url_to_did", + 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 := dw.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 (dw *DIDWebFuncs) 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 (dw *DIDWebFuncs) urlToDID(uri *url.URL) *DID { + p := strings.TrimSuffix(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_web_test.go b/internal/regofunc/did_web_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4e00f0af9194386e02d2d9754a607ac95ab36cd9 --- /dev/null +++ b/internal/regofunc/did_web_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 TestDIDToURLFunc(t *testing.T) { + tests := []struct { + // test input + name string + regoQuery string + // expected result + res string + errText string + }{ + { + name: "DID is empty", + regoQuery: `did_to_url("")`, + errText: "DID cannot be empty", + }, + { + name: "invalid DID", + regoQuery: `did_to_url("invalid-did")`, + errText: "invalid DID, host is not found", + }, + { + name: "invalid DID Method", + regoQuery: `did_to_url("did:sov:123456qwerty")`, + errText: "invalid DID, method is unknown", + }, + { + name: "transformation success with DID containing domain only", + regoQuery: `did_to_url("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: `did_to_url("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: `did_to_url("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.NewDIDWebFuncs() + + r := rego.New( + rego.Query(test.regoQuery), + rego.Function1(DIDTransformerFuncs.DIDToURLFunc()), + 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 TestURLToDIDFunc(t *testing.T) { + tests := []struct { + // test input + name string + regoQuery string + // expected result + res string + errText string + }{ + { + name: "empty URL", + regoQuery: `url_to_did("")`, + errText: "URL cannot be empty", + }, + { + name: "URL containing special characters", + regoQuery: `url_to_did("example.com\nH1234")`, + errText: "cannot parse URL", + }, + { + name: "URL does not contain secure protocol (https)", + regoQuery: `url_to_did("example.com")`, + errText: "invalid URL for did:web method", + }, + { + name: "URL does not contain valid domain", + regoQuery: `url_to_did("https://")`, + errText: "invalid URL for did:web method", + }, + { + name: "transformation success with URL containing domain only", + regoQuery: `url_to_did("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: `url_to_did("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: `url_to_did("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.NewDIDWebFuncs() + + r := rego.New( + rego.Query(test.regoQuery), + rego.Function1(DIDTransformerFuncs.URLToDIDFunc()), + 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) + } + }) + } +}