Article
· Jan 4 22m read

The Security Package - REST API Considerations

So far, we have covered how to use ObjectScript to manage users, roles, resources, and applications. There are a few other classes in this package that work similarly to the ones mentioned above. However, these four classes are the ones everyone will have to use to manage their application security. Suppose you wanted to create your own Security management portal for this package. There would be some specific issues to think about for an API. Since the classes use similar methods, we can create fewer API endpoints using indirection. However, we should consider differences between those methods based on the classes. If we do that, we can keep things more consistent for the API calls.

Before we go further into details, I would be doing a disservice to you if I did not make you aware of the existing REST API by @Lorenzo Scalese. Whereas this article is an exercise on how to approach the creating process yourself, Lorenzo’s piece is also an excellent source to refer to.

We also have to address security. Since this is a safety feature, we should be tempted to secure it using the %Admin_Secure resource, but it may not be the best solution. Users who do not have that resource may want to change some of their personal information, such as their full name, password, phone number, phone provider, or email address. We could accomplish that by maintaining two separate APIs: one with the methods for the basic users to update their private information and one with everything else. However, since we are learning about security, we should challenge ourselves and try securing different endpoints using various resources.

As always, our API will have to extend the %CSP.REST class. We will start by creating an Abstract class that extends the %CSP.REST class and overrides some of its pieces. First, we should look at the XData Schema block, where we will see the XML definitions against which the URL map is validated. The Route nodes are defined to require a Url, Method, and Call attribute. There is also an optional Cors attribute. Our task here would be to add another attribute definition as follows:

<xs:attribute name="Resource" type="string" use="optional" default=" "/>

Once we have added that to the XData schema, we can include a Resource attribute in our Route nodes in the URL map. However, if we had not added that to the XData Schema, it would not have compiled classes that had the Resource attribute. In our case, we will take the Resource attribute in the form of resource:permission to uniform it with other parts of the Security package. The Route node might look like the following:

<Route Url="/securetest" Method="GET" Call="securetest" Resource="MyResource:U" />

Next, we must tell our class what to do with that information if we include it. If it is included, we want to make sure users have that resource before we let them access the API endpoint. To do that, we should first look at the DispatchMap method. This method is a generator method. It reads the URL map and produces code based on that information. Examine the block of code starting with If tMap(tChild)=”Route” to understand the handling of Route nodes. We can notice a line of code that substitutes capture groups for arguments, so we would like to edit it to add to our new Resource attribute:

Set tPattern="",tArgCount=0,tUrl=tMap(tChild,"a","Url"),tCors=tMap(tChild,"a","Cors"),tResource=tMap(tChild,"a","Resource")

We have just inserted the Resource attribute into a variable called tResource. A few lines below that, there is a string starting with $$$GENERATE. It creates a list other methods can use in our class. We will need to include our tResource there as well, so let's edit it:

$$$GENERATE(" If pIndex="_tCounter_" Quit $ListBuild(""R"","""_tPattern_""","""_tMethod_""","""_tCall_""","""_tCors_""","""_tResource_""")")

At this point, we have made the Resource retrievable but haven’t used it to our advantage. The final method we should modify is the DispatchRequest method. It uses DispatchMap to decide what exactly needs to be done with the REST API request. Near the beginning of that method, we will set a variable called tSecurityResourceMatched:

Set tSecurityResourceMatched = 1

This method then uses a regex matcher to find the map entry that matches the request URL. There is a block of code starting with If tMatcher.Match(tMatchUrl). That piece gets executed when it has found the method matching the URL. At this point, tMapEntry has already been set to the correct DispatchMap. The tResource we added to the end is the sixth entry in that list, so to get our tResource, we should simply use the following:

set tResource = $List(tMapEntry,6)

The tResource defaults to a single space, so if it is anything but a space we want to do a security check on the resource and permission we put into the Resource attribute, like this:

If tResource '= " "{
    If $SYSTEM.Security.Check($P(tResource,":",1),$P(tResource,":",2))=0{
            Set tSecurityResourceMatched=0
        }
}

What we want to do here is to wrap the rest of the handling of the Route nodes in an if statement in order to make it execute only if tSecurityResourceMatched = 1. After that, at the bottom, we need to tell it what to do if tSecurityResourceMatched is zero. Near the bottom of the method, you can also see how to handle several other possible errors. There is one for when no matching API resource is found and another one for when the API resource is found, but its method isn’t defined. Before those remarks, let’s add the following:

If tSecurityResourceMatched = 0 Set tSC=..ReportHttpStatusCode(..#HTTP401UNAUTHORIZED) Quit

At this stage, if we include a Resource in a Route node, that route will give a 401 Unauthorized HTTP error to users who do not have that resource as we intended. We will save and compile this class, then extend it for our Security package API. If you have not been following along and creating this class, don't worry! You can find the complete source code for this class at the bottom of this article. I will call it REST.Resourceful.

Now that we have puzzled through all the abovementioned things, we can consider how we might develop an API and what concerns we may get. Since the methods within the classes are so uniform, if we are willing to utilize some indirection, we can get a lot of uses from a small number of endpoints. Consider the upcoming URL route and method:

<Route Url="/get/:classname/:name" Method="GET" Call="Get" Resource="%Admin_Secure:U" />
ClassMethod Get(classname As %String, name As %String) [ PublicList = (props, sc) ]
{
    set %response.ContentType="application/json"
    zn "%SYS"
    if classname="Applications"{
         set name=$REPLACE(name,"%2F","/")
    }
    set code = "sc=##class(Security."_classname_").Get("""_name_""",.props)"
    set @code
    set respObj={}.%New()
    do ##class(%CSP.REST).StatusToJSON(sc,.scJSON)
    do respObj.%Set("status",scJSON)
    set index = $O(props(""))
    while index '= ""{
        do respObj.%Set(index,props(index))
        set index = $O(props(index))
    }
    write respObj.%ToJSON()
    return $$$OK
}

The abovementioned code uses indirection to get the properties array from any class you specify and returns it as JSON. However, you should notice that a handling for the Applications class is somehow special. Since they frequently contain forward slashes in their names, they do not always play nicely with REST API requests. Therefore, depending on the way your web server is configured, you will need to replace them with either %2F or %252F in your request.

We can use similar routes and procedures for every standard method in the Security package:

<Route Url="/exists/:classname/:name" Method="GET" Call="Exists" Resource="%Admin_Secure:U" />
<Route Url="/delete/:classname/:name" Method="DELETE" Call="Delete" Resource="%Admin_Secure:U" />
<Route Url="/modify/:classname/:name" Method="POST" Call="Get" Resource="%Admin_Secure:U" />
<Route Url="/create/:classname/:name" Method="PUT" Call="Get" Resource="%Admin_Secure:U" />

We can make exceptions for certain classes along the way so that even if the method in the Security package slightly differs from the norm (as discussed in the previous articles), we can deal with it. For instance, whereas in some cases the Create or Modify methods can use a properties array, in other classes they require a list of arguments. You could always accept JSON so that the usage is consistent within the API. Yet, remember to make those crucial changes inside the API.

Now consider the end users who want to edit their personal information. We can create an endpoint for them without a defined resource using the code below:

<Route Url="/editme" Method="POST" Call="EditMe" />
ClassMethod EditMe() As %Status
{
    set %response.ContentType="application/json"
    zn "%SYS"
    set reqjson = %request.Content.Read()
    set reqObj = {}.%FromJSON(reqjson)
    set sc = ##class(Security.Users).Get($USERNAME,.oldprops)
    if reqObj.%Get("FullName") = ""{
        set props("FullName") = oldprops("FullName")
    }
    else{
        set props("FullName") = reqObj.%Get("FullName")
    }
    if reqObj.%Get("PhoneNumber") = ""{
        set props("PhoneNumber") = oldprops("PhoneNumber")
    }
    else{
        set props("PhoneNumber") = reqObj.%Get("PhoneNumber")
    }
    if reqObj.%Get("PhoneProvider") = ""{
        set props("PhoneProvider") = oldprops("PhoneProvider")
    }
    else{
        set props("PhoneProvider") = reqObj.%Get("PhoneProvider")
    }
    if reqObj.%Get("EmailAddress") = ""{
        set props("EmailAddress") = oldprops("EmailAddress")
    }
    else{
        set props("EmailAddress") = reqObj.%Get("EmailAddress")
    }
    set sc = ##class(Security.Users).Modify($USERNAME,.props)
    set respObj = {}.%New()
    do ##class(%CSP.REST).StatusToJSON(sc,.scJSON)
    do respObj.%Set("Status",scJSON)
    write respObj.%ToJSON()
    return $$$OK
}

The abovementioned code will allow them to edit exclusively the full name, phone number, phone provider, and email address for their username. In this method, we ought to be really careful with picking out only those pieces we want to allow.

If you keep these principles in mind, it will be a piece of cake for you to create your own basic Security API.

The code for our customized %CSP.REST class follows:

/// Extends the %CSP.REST class to include securing of endpoints by resource in the URL map
Class REST.Resourceful Extends %CSP.REST [ Abstract ]
{
XData Schema [ Internal ]
{
<?xml version="1.0"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" >
<xs:element name="Routes">
<xs:complexType>
<xs:choice  minOccurs="0" maxOccurs="unbounded">
<xs:element name="Route">
<xs:complexType>
<xs:attribute name="Url"    type="string" use="required"/>
<xs:attribute name="Method" type="string" use="required"/>
<xs:attribute name="Call" type="call" use="required"/>
<xs:attribute name="Cors" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="Resource" type="string" use="optional" default=" "/>
</xs:complexType>
</xs:element>
<xs:element name="Map">
<xs:complexType>
<xs:attribute name="Prefix" type="string" use="required"/>
<xs:attribute name="Forward" type="forward" use="required"/>
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
<xs:simpleType name="call">
<xs:restriction base="xs:string">
<xs:pattern value="([%]?[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)*:)?[%]?[a-zA-Z][a-zA-Z0-9]*"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="forward">
<xs:restriction base="xs:string">
<xs:pattern value="([%]?[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)*)"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="string">
<xs:restriction base="xs:string">
<xs:minLength value="1"/>
</xs:restriction>
</xs:simpleType>
</xs:schema>
}
/// This generator creates the DispatchMap Method used to dispatch the URL and Method to the associated target method
ClassMethod DispatchMap(pIndex As %Integer) As %String [ CodeMode = generator ]
{
#dim tSC As %Status = $$$OK
    #dim e As %Exception.AbstractException
   #dim tStream As %Stream.TmpCharacter
    #dim tHandler As %XML.ImportHandler
    #dim tCompiledClass As %Dictionary.CompiledClass
    #dim tArgCount,tIndex,tI,tCounter As %Integer
    #dim tArgs,tChild,tClassName,tCall,tCors,tForward,tError,tMap,tMethod,tPattern,tPiece,tPrefix,tType,tUrl,tResource As %String
    Try {
        Set tClassName=%classname
        #; Don't run on base class
        If tClassName="%CSP.REST" Quit
        #; Find named XDATA block
        If ##class(%Dictionary.CompiledXData).%ExistsId(tClassName_"||UrlMap") {
            Set tCompiledClass=##class(%Dictionary.CompiledClass).%OpenId(tClassName,,.tSC)
            If '$IsObject(tCompiledClass)||$$$ISERR(tSC) Quit
            Set tIndex = tCompiledClass.XDatas.FindObjectId(tClassName_"||UrlMap")
            If tIndex="" Set tSC=$$$ERROR($$$XDataBlockMissing,tClassName,"UrlMap") Quit
            #; Get XDATA as stream
            Set tStream = tCompiledClass.XDatas.GetAt(tIndex).Data
            Do tStream.Rewind()
            #; Create an XML import handler ( use the internal handler )
            Set tHandler=##class(%XML.ImportHandler).%New("CacheTemp",$$$IntHandler)
            #; Create the Entity Resolver
            Set tResolver=##class(%XML.SAX.XDataEntityResolver).%New(tClassName)
            #; Parse the XML data in the specfied stream
            Set tSC=##Class(%XML.SAX.Parser).ParseStream(tStream,tHandler,tResolver,,,"Schema")
            If $$$ISERR(tSC) Quit       
            #; Copy tree because handler will delete its copy when it goes out of scope
            Merge tMap=@tHandler.DOMName@(tHandler.Tree)
            If $Data(tMap("error"))||$Data(tMap("warning")) {
                Set tSC=$$$ERROR($$$InvalidDispatchMap)
        For tType="error","warning" {
                    Set tIndex = "" For {
                        Set tIndex=$Order(tMap(tType,tIndex),1,tError) If tIndex="" Quit
                        Set tSC=$$$ADDSC(tSC,$$$ERROR($$$GeneralError,tError))
                    }
                }
                Quit
            }
            #; Walk the xml and generate the routing map
            Set tChild="",tCounter=0 
            For {
                Set tChild=$Order(tMap(1,"c",tChild)) If tChild="" Quit
                If tMap(tChild)="Route" {
                #; Need to substitute capture groups for arguments
                #; Added setting of tResource based on URL map - DLH
                Set tPattern="",tArgCount=0,tUrl=tMap(tChild,"a","Url"),tCors=tMap(tChild,"a","Cors"),tResource=tMap(tChild,"a","Resource")
                #; Substitute variable placeholders for capture group
                For tI=1:1:$Length(tUrl,"/") {
                    Set tPiece=$Piece(tUrl,"/",tI)
                    If $Extract(tPiece)=":" {
                        Set $Piece(tPattern,"/",tI)="([^"_$Char(0)_"]+)"
                    } else {
                        Set $Piece(tPattern,"/",tI)=tPiece                  }
                }
                Set tPattern=$Translate(tPattern,$Char(0),"/")
                Set tCounter=$Increment(tCounter),tMethod=tMap(tChild,"a","Method"),tCall=$Get(tMap(tChild,"a","Call"))
                #; Added getting resource from the URL Map here. - DLH
                $$$GENERATE(" If pIndex="_tCounter_" Quit $ListBuild(""R"","""_tPattern_""","""_tMethod_""","""_tCall_""","""_tCors_""","""_tResource_""")")
            } else {
                Set tCounter=$Increment(tCounter),tPrefix=tMap(tChild,"a","Prefix"),tForward=$Get(tMap(tChild,"a","Forward"))
                #; Need to substitute capture groups for arguments
                Set tPattern=""
                For tI=2:1:$Length(tPrefix,"/") {
                    Set tPiece=$Piece(tPrefix,"/",tI)
                    If $Extract(tPiece)=":" {
                        Set tPattern=tPattern_"/[^/]+"
                    } else {
                        Set tPattern=tPattern_"/"_tPiece
                    }
                }
                Set tPattern = "("_ tPattern _ ")/.*"
                $$$GENERATE(" If pIndex="_tCounter_" Quit $ListBuild(""M"","""_tPattern_""","""_tForward_""")")
            }
            }
            $$$GENERATE(" Quit """"")
        } else {
            #; The specified class must have an XDATA Block named UrlMap
            Set tSC=$$$ERROR($$$XDataBlockMissing,tClassName,"UrlMap")
        }
    } Catch (e) {
        Set tSC=e.AsStatus()
    }
    Quit tSC
}
/// Dispatch a REST request according to URL and Method
ClassMethod DispatchRequest(pUrl As %String, pMethod As %String, pForwarded As %Boolean = 0) As %Status
{
    #dim tSC As %Status = $$$OK
    #dim e As %Exception.AbstractException
    #dim tMatcher As %Regex.Matcher
    #dim tArgs,tClass,tMatchUrl,tMapEntry,tRegEx,tCall,tForward,tAccess,tSupportedVerbs,tTarget,tType As %String
    #dim tI,tIndex As %Integer
    #dim tResourceMatched,tContinue As %Boolean
    #dim tMethodMatched As %Boolean
    Try {
        Set (tResourceMatched,tMethodMatched)=0
        #; Initializing tSecurityResourceMatched - DLH
        Set tSecurityResourceMatched = 1
        #; Extract the match url from the application name
        If (0=pForwarded) {
            Set tMatchUrl="/"_$Extract(pUrl,$Length(%request.Application)+1,*)
        } else {
            Set tMatchUrl=pUrl
        }
        #; Uppercase the method
        Set pMethod=$ZCVT(pMethod,"U")
        #; Pre-Dispatch
        Set tContinue=1,tSC=..OnPreDispatch(tMatchUrl,pMethod,.tContinue)
        If $$$ISERR(tSC) Quit
        #; It's the users responsibility to return the response in OnPreDispatch() if Continue = 0
        If tContinue=0 Quit
        #; Walk the dispatch map in collation order of defintion
        For tIndex=1:1 {
            #; Get the next map entry
            Set tMapEntry=..DispatchMap(tIndex) If tMapEntry="" Quit
            #; Pick out the RegEx
            Set tRegEx=$List(tMapEntry,2)
            #; Create a matcher
            Set tMatcher=##class(%Regex.Matcher).%New(tRegEx)
            #; Test each regular expression in turn, extracting the arguments,
            #; dispatching to the named method
            If tMatcher.Match(tMatchUrl) {
                #; We have matched the resource
                Set tResourceMatched=1
                #; Logic to check the resource from the URL map
                set tResource = $List(tMapEntry,6)
                If tResource '= " "{
                    If $SYSTEM.Security.Check($P(tResource,":",1),$P(tResource,":",2))=0{
                        Set tSecurityResourceMatched=0
                    }
                }
     #; Added an if so the method only gets dispatched if we have the resource permission
                If tSecurityResourceMatched = 1{
                Set tType=$List(tMapEntry,1)
                 #; If we are a simple route
                 If tType="R" {
                     #; Support OPTIONS VERB (cannot be overriden)
                    If pMethod="OPTIONS" {
                         Set tMethodMatched=1
                         Set tSC=..OnHandleOptionsRequest(tMatchUrl)
                         If $$$ISERR(tSC) Quit
                         #; Dispatch CORS
                         Set tSC=..ProcessCorsRequest(pUrl,$list(tMapEntry,5))
                         If $$$ISERR(tSC) Quit
                         Quit
                     }
                     #; comparison is case-insensitive now
                     If pMethod'=$ZCVT($List(tMapEntry,3),"U") Continue
                     Set tTarget=$List(tMapEntry,4)
                     #; We have matched a method
                     Set tMethodMatched=1
                     #; Dispatch CORS
                     Set tSC=..ProcessCorsRequest(pUrl,$list(tMapEntry,5))
                     If $$$ISERR(tSC) Quit
                     #; Got a match, marshall the arguments can call directly
                     If tMatcher.GroupCount {
                         For tI=1:1:tMatcher.GroupCount Set tArgs(tI)=tMatcher.Group(tI)
                         Set tArgs=tI
                     } else {
                         Set tArgs=0
                     }
                     #; Check for optional ClassName prefix
                     Set tClass=$classname()
                     If tTarget[":" Set tClass=$Piece(tTarget,":"),tTarget=$Piece(tTarget,":",2)
                     #; Dispatch
                     Set tSC=$zobjclassmethod(tClass,tTarget,tArgs...)
                 } else {
                     #; We are a map, massage the URL and forward the request
                     Set tMatchUrl=$piece(tMatchUrl,tMatcher.Group(1),"2",*),tForward=$ListGet(tMapEntry,3)
                     Set (tResourceMatched,tMethodMatched)=1
                     #; Dispatch with modified URL
                     Set tSC=$zobjclassmethod(tForward,"DispatchRequest",tMatchUrl,pMethod,1)
                 }
                 If $$$ISERR(tSC) Quit
                 #; Don't want multiple matches
                 Quit
                }
            }
        }
        #; Didn't have permission for the resource required by this enpoint; return 401 - DLH
        If tSecurityResourceMatched = 0 Set tSC=..ReportHttpStatusCode(..#HTTP401UNAUTHORIZED) Quit
        #; Didn't have a match for the resource, report not found
        If tResourceMatched=0 Set tSC=..ReportHttpStatusCode(..#HTTP404NOTFOUND) Quit
        #; Had a match for resource but method not matched
        If tMethodMatched=0 {
            Set tSC=..SupportedVerbs(tMatchUrl,.tSupportedVerbs)
            If $$$ISERR(tSC) Quit
            Set tSC=..Http405(tSupportedVerbs) Quit
        }
    } Catch (e) {
        Set tSC=e.AsStatus()
    }
    Quit tSC
}
}
Discussion (2)2
Log in or sign up to continue

Hi @David Hockenbroch

I think I've found another way to protect a route.
I'm not protecting the route itself, but the method it points by using [ Requires = "myResource:Use" ] on the called method:

 

/// Sample API
Class Test.Api Extends %CSP.REST
{

 XData UrlMap [ XMLNamespace = "http://www.intersystems.com/urlmap" ]
 {
	<Routes>
		<Route Url="/test" Method="GET" Call="Test" />
	</Routes>
 }

 ClassMethod Test() As %Status [ Requires = "myResource:Use" ]
 {
  Do ##class(%REST.Impl).%WriteResponse("api method test : OK")
  Return $$$OK
 }
}