Show Navigation

Custom Tenant Resolver by Current Logged in User

Learn how to create a custom tenant resolver and use Grails Multi-Tenancy capabilities to switch tenants based on the current logged user or by a JWT.

Authors: Sergio del Amo

Grails Version: 4.0.1

1 Grails Training

Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.

2 Getting Started

GORM Multi-Tenancy Support includes several Built-In Tenant resolvers. In this guide, you are going to create your own tenant resolver to switch tenants based on the logged user in a Grails rest-api application which uses Spring Security REST plugin.

2.1 What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • JDK 1.8 or greater installed with JAVA_HOME configured appropriately

2.2 How to complete the guide

To get started do the following:

or

The Grails guides repositories contain two folders:

  • initial Initial project. Often a simple Grails app with some additional code to give you a head-start.

  • complete A completed example. It is the result of working through the steps presented by the guide and applying those changes to the initial folder.

To complete the guide, go to the initial folder

  • cd into grails-guides/grails-custom-security-tenant-resolver/initial

and follow the instructions in the next sections.

You can go right to the completed example if you cd into grails-guides/grails-custom-security-tenant-resolver/complete

3 Writing the Application

This guide uses Multi-Tenancy DISCRIMINATOR mode. To learn more, read Single Database Multi-Tenancy Discriminator Column Guide.

3.1 Create Domain Class

grails-app/domain/demo/Plan.groovy
package demo

import grails.gorm.MultiTenant

class Plan implements MultiTenant<Plan> { (1)
    String title
    String username

    static mapping = {
        tenantId name: 'username' (2)
    }
}
1 Implement MultiTenant trait to regard this domain class as multi tenant.
2 Setup the tenant identifier as a column named username

Create an interface PlanService which is a GORM Data Service.

grails-app/services/demo/PlanDataService.groovy
package demo

import grails.gorm.multitenancy.CurrentTenant
import grails.gorm.services.Service

@Service(Plan) (1)
@CurrentTenant (2)
interface PlanDataService {
    List<Plan> findAll()
    Plan save(String title)
    void deleteByTitle(String title)
}
1 Annotate it with the grails.gorm.services.Service annotation with the domain class the service applies to.
2 Resolve the current tenant for the context of this class

3.2 Controller

Create a simple controller which uses with the previously defined PlanService service.

grails-app/controllers/demo/PlanController.groovy
package demo

import groovy.transform.CompileStatic

@CompileStatic
class PlanController {
    PlanDataService planDataService

    def index() {
        [planList: planDataService.findAll()]
    }
}

We render the Plan instances as JSON with the help of JSON Views

grails-app/views/plan/index.gson
import demo.Plan

model {
    Iterable<Plan> planList
}

json tmpl.plan('plan', planList)
grails-app/views/plan/_plan.gson
import demo.Plan

model {
    Plan plan
}

json {
    title plan.title
}

3.3 Wire up Security

Create security domain classes. You could create these classes with Spring Security Core s2-quickstart.

grails-app/domain/demo/User.groovy
package demo

import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
@EqualsAndHashCode(includes='username')
@ToString(includes='username', includeNames=true, includePackage=false)
class User implements Serializable {

    private static final long serialVersionUID = 1

    String username
    String password
    boolean enabled = true
    boolean accountExpired
    boolean accountLocked
    boolean passwordExpired

    Set<Role> getAuthorities() {
        (UserRole.findAllByUser(this) as List<UserRole>)*.role as Set<Role>
    }

    static constraints = {
        password nullable: false, blank: false, password: true
        username nullable: false, blank: false, unique: true
    }

    static mapping = {
            password column: '`password`'
    }
}
grails-app/domain/demo/Role.groovy
package demo

import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
@EqualsAndHashCode(includes='authority')
@ToString(includes='authority', includeNames=true, includePackage=false)
class Role implements Serializable {

        private static final long serialVersionUID = 1

        String authority

        static constraints = {
                authority nullable: false, blank: false, unique: true
        }

        static mapping = {
                cache true
        }
}

Create a class UserPasswordEncoderListener which deals with the User’s password encoding.

It uses the @Listener annotation to listen synchronously to Events from GORM.

src/main/groovy/demo/UserPasswordEncoderListener.groovy
package demo

import grails.plugin.springsecurity.SpringSecurityService
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
import org.springframework.beans.factory.annotation.Autowired
import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic

@CompileStatic
class UserPasswordEncoderListener {

    @Autowired
    SpringSecurityService springSecurityService

    @Listener(User)
    void onPreInsertEvent(PreInsertEvent event) {
        encodePasswordForEvent(event)
    }

    @Listener(User)
    void onPreUpdateEvent(PreUpdateEvent event) {
        encodePasswordForEvent(event)
    }

    private void encodePasswordForEvent(AbstractPersistenceEvent event) {
        if (event.entityObject instanceof User) {
            User u = event.entityObject as User
            if (u.password && ((event instanceof PreInsertEvent) || (event instanceof PreUpdateEvent && u.isDirty('password')))) {
                event.getEntityAccess().setProperty('password', encodePassword(u.password))
            }
        }
    }

    private String encodePassword(String password) {
        springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
    }
}

Define the previous listener as a bean:

grails-app/conf/spring/resources.groovy
import demo.UserPasswordEncoderListener
beans = {
    userPasswordEncoderListener(UserPasswordEncoderListener)
}

Configure Spring Security Core in application.yml

grails-app/conf/application.yml
grails
grails:
    plugin:
        springsecurity:
            rest:
                token:
                    storage:
                        jwt:
                            secret: qrD6h8K6S9503Q06Y6Rfk21TErImPYqa # change this for a new one
            securityConfigType: InterceptUrlMap
            userLookup:
                userDomainClassName: demo.User
                authorityJoinClassName: demo.UserRole
            authority:
                className: demo.Role
            filterChain:
                chainMap:
                    - # Stateless chain
                        pattern: /**
                        filters: 'JOINED_FILTERS,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'
            interceptUrlMap:
                -
                    pattern: /
                    access:
                        - ROLE_VILLAIN
                -
                    pattern: /error
                    access:
                        - permitAll
                -
                    pattern: /api/login
                    access:
                        - ROLE_ANONYMOUS
                -
                    pattern: /api/validate
                    access:
                        - ROLE_ANONYMOUS
                -
                    pattern: /oauth/access_token
                    access:
                        - ROLE_ANONYMOUS
                -
                    pattern: /plan
                    access:
                        - ROLE_VILLAIN

Create several GORM Data Services to work with the Domain Classes involved in the securization of our app.

grails-app/services/demo/RoleDataService.groovy
package demo

import grails.gorm.services.Service

@Service(Role)
interface RoleDataService {
    void delete(Serializable id)
    void deleteByAuthority(String authority)
    Role findByAuthority(String authority)
    Role saveByAuthority(String authority)
}
grails-app/services/demo/UserDataService.groovy
package demo

import grails.gorm.services.Service
import groovy.transform.CompileStatic

@CompileStatic
@Service(User)
interface UserDataService {
    User save(String username, String password)
    void delete(Serializable id)
    User findByUsername(String username)
}
grails-app/services/demo/UserRoleDataService.groovy
package demo

import grails.gorm.services.Query
import grails.gorm.services.Service
import groovy.transform.CompileStatic

@Service(UserRole)
@CompileStatic
interface UserRoleDataService {

    @Query("""select $user.username from ${UserRole userRole}
    inner join ${User user = userRole.user}
    inner join ${Role role = userRole.role}
    where $role.authority = $authority""")
    List<String> findAllUsernameByAuthority(String authority)

    UserRole save(User user, Role role)

    void delete(User user, Role role)

    void deleteByUser(User user)
}

Create a service to handle User creation with a specific role.

grails-app/services/demo/UserService.groovy
package demo

import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic

@CompileStatic
class UserService {
    RoleDataService roleDataService

    UserRoleDataService userRoleDataService

    UserDataService userDataService

    @Transactional
    User save(String username, String password, String authority) {
        Role role = roleDataService.findByAuthority(authority)
        if ( !role ) {
            role = roleDataService.saveByAuthority(authority)
        }
        User user = userDataService.save(username, password)
        userRoleDataService.save(user, role)
        user
    }
}

3.4 Custom Tenant Resolver

The following table contains all of the TenantResolver implementations that ship with GORM and are usable out of the box. The package name has been shorted from org.grails.datastore.mapping to o.g.d.m for brevity:

name description

o.g.d.m.multitenancy.resolvers.FixedTenantResolver

Resolves against a fixed tenant id

o.g.d.m.multitenancy.resolvers.SystemPropertyTenantResolver

Resolves the tenant id from a system property called gorm.tenantId

o.g.d.m.multitenancy.web.SubDomainTenantResolver

Resolves the tenant id from the subdomain via DNS

o.g.d.m.multitenancy.web.CookieTenantResolver

Resolves the current tenant from an HTTP cookie named gorm.tenantId by default

o.g.d.m.multitenancy.web.SessionTenantResolver

Resolves the current tenant from the HTTP session using the attribute gorm.tenantId by default

However, you have the flexibility to create your a custom tenant resolver is easy. In order to do that, create a class which implements org.grails.datastore.mapping.multitenancy.TenantResolver.

The next sections will show you how to create two custom tenant resolvers.

3.5 Tenant Resolver by JWT

Grails Spring Security REST plugin allows you to authenticate your application with a JWT token present in the Authorization header.

Example: A request to /plan contains a JWT token in the Authorization header.

## Request Duplicate
curl "http://localhost:8080/plan" \
     -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9....." \
     -H "Accept: application/json"

We can create Custom Tenant Resolver which extracts the JWT token, re-hydrates it and extracts the username.

src/main/groovy/demo/CurrentUserByJwtTenantResolver.groovy
package demo

import grails.gorm.DetachedCriteria
import grails.plugin.springsecurity.rest.token.storage.TokenStorageService
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.context.request.RequestAttributes
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletWebRequest

import javax.servlet.http.HttpServletRequest

@CompileStatic
class CurrentUserByJwtTenantResolver implements AllTenantsResolver { (1)

    public static final String HEADER_NAME = 'Authorization'
    public static final String HEADER_VALUE_PREFFIX = 'Bearer '

    String headerName = HEADER_NAME
    String headerValuePreffix = HEADER_VALUE_PREFFIX

    @Autowired
    TokenStorageService tokenStorageService

    @Override
    Serializable resolveTenantIdentifier() throws TenantNotFoundException {

        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes()
        if(requestAttributes instanceof ServletWebRequest) {

            HttpServletRequest httpServletRequest = ((ServletWebRequest) requestAttributes).getRequest()
            String token = httpServletRequest.getHeader(headerName.toLowerCase())
            if ( !token ) {
                throw new TenantNotFoundException("Tenant could not be resolved from HTTP Header: ${headerName}")
            }

            if (token.startsWith(headerValuePreffix)) {
                token = token.substring(headerValuePreffix.length())
            }
            UserDetails userDetails = tokenStorageService.loadUserByToken(token)
            String username = userDetails?.username
            if ( username ) {
                return username
            }
            throw new TenantNotFoundException("Tenant could not be resolved from HTTP Header: ${headerName}")
        }
        throw new TenantNotFoundException("Tenant could not be resolved outside a web request")
    }

    @Override
    Iterable<Serializable> resolveTenantIds() {
        User.withTransaction(readOnly: true) {
            new DetachedCriteria(User)
                    .distinct('username')
                    .list()  as Iterable<Serializable>
        }
    }
}
1 When using discriminator-based multi-tenancy then you may need to implement the AllTenantsResolver interface in your TenantResolver implentation if you want to at any point iterate over all available tenants. AllTenantsResolver extends `TenantResolver. To create your own tenant resolver implement org.grails.datastore.mapping.multitenancy.TenantResolver.

Define the Custom Tenant Resolver as a bean:

grails-app/conf/spring/resources.groovy
import demo.CurrentUserByJwtTenantResolver
beans = {
    currentUserByJwtTenantResolver(CurrentUserByJwtTenantResolver)
}

Create an Integration Test to test the custom Tenant Resolver.

src/integration-test/groovy/demo/CurrentUserByJwtTenantResolverSpec.groovy
package demo

import grails.plugin.springsecurity.rest.token.AccessToken
import grails.plugin.springsecurity.rest.token.generation.TokenGenerator
import grails.testing.mixin.integration.Integration
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletWebRequest
import spock.lang.Specification
import spock.lang.IgnoreIf

@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
@Integration
class CurrentUserByJwtTenantResolverSpec extends Specification {

    @Autowired
    CurrentUserByJwtTenantResolver currentUserTenantResolver

    TokenGenerator tokenGenerator


    void "Test HttpHeader resolver throws an exception outside a web request"() {
        when:
        currentUserTenantResolver.resolveTenantIdentifier()

        then:
        def e = thrown(TenantNotFoundException)
        e.message == "Tenant could not be resolved outside a web request"
    }


    void "Test not tenant id found"() {
        setup:
        def request = new MockHttpServletRequest("GET", "/foo")
        RequestContextHolder.setRequestAttributes(new ServletWebRequest(request))

        when:
        currentUserTenantResolver.resolveTenantIdentifier()

        then:
        def e = thrown(TenantNotFoundException)
        e.message == "Tenant could not be resolved from HTTP Header: ${CurrentUserByJwtTenantResolver.HEADER_NAME}"

        cleanup:
        RequestContextHolder.setRequestAttributes(null)
    }

    void "Test HttpHeader value is the tenant id when a request is present"() {

        setup:
        MockHttpServletRequest request = new MockHttpServletRequest("GET", "/foo")
        def userDetails = Stub(UserDetails) {
            getUsername() >> 'vector'
            isAccountNonExpired() >> true
            isAccountNonLocked() >> true
            isCredentialsNonExpired() >> true
            isEnabled() >> true
        }
        AccessToken accessToken = tokenGenerator.generateAccessToken(userDetails, 3600)
        String jwt = accessToken.accessToken
        request.addHeader('Authorization', "Bearer $jwt")
        RequestContextHolder.setRequestAttributes(new ServletWebRequest(request))


        when:
        def tenantId = currentUserTenantResolver.resolveTenantIdentifier()

        then:
        tenantId == "vector"

        cleanup:
        RequestContextHolder.setRequestAttributes(null)
    }
}

Next were are going to configure our app to use this custom tenant resolver.

Configure Multi-Tenancy by modifying application.yml.

grails-app/conf/application.yml
grails
    gorm:
        multiTenancy:
            mode: DISCRIMINATOR (1)
            tenantResolverClass: demo.CurrentUserByJwtTenantResolver (2)
        reactor:
            # Whether to translate GORM events into Reactor events
            # Disabled by default for performance reasons
            events: false
1 Define Multi-Tenancy mode as DISCRIMINATOR
2 Set the Tenant Resolver class with a custom class which you will write later in this guide.

3.6 Tenant Resolver by User

Alternatively, you may want to use a combination of Stateless endpoints secured by Spring Security REST and Stateful endpoints secured by Spring Security Core. You will be interested to create a custom resolver by the current logged in user.

src/main/groovy/demo/CurrentUserTenantResolver.groovy
package demo

import grails.gorm.DetachedCriteria
import grails.plugin.springsecurity.SpringSecurityService
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import org.grails.datastore.mapping.multitenancy.AllTenantsResolver
import org.grails.datastore.mapping.multitenancy.TenantResolver
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Lazy
import org.springframework.security.core.userdetails.UserDetails

@CompileStatic
class CurrentUserTenantResolver implements AllTenantsResolver { (1)

    @Autowired
    @Lazy
    SpringSecurityService springSecurityService

    @Override
    Serializable resolveTenantIdentifier() throws TenantNotFoundException {
        String username = loggedUsername()
        if ( username ) {
            return username
        }
        throw new TenantNotFoundException("Tenant could not be resolved from Spring Security Principal")
    }

    String loggedUsername() {
        if ( springSecurityService.principal instanceof String ) {
            return springSecurityService.principal
        }
        if (springSecurityService.principal instanceof UserDetails) {
            return ((UserDetails) springSecurityService.principal).username
        }
        null
    }

    @Override
    Iterable<Serializable> resolveTenantIds() {
        User.withTransaction(readOnly: true) {
            new DetachedCriteria(User)
                    .distinct('username')
                    .list()  as Iterable<Serializable>
        }
    }
}
1 When using discriminator-based multi-tenancy then you may need to implement the AllTenantsResolver interface in your TenantResolver implentation if you want to at any point iterate over all available tenants. AllTenantsResolver extends `TenantResolver. To create your own tenant resolver implement org.grails.datastore.mapping.multitenancy.TenantResolver.

Define the Custom Tenant Resolver as a bean:

grails-app/conf/spring/resources.groovy
import demo.CurrentUserTenantResolver
beans = {
    currentUserTenantResolver(CurrentUserTenantResolver)
}

Create an Integration Test to test the custom Tenant Resolver.

src/integration-test/groovy/demo/CurrentUserTenantResolverSpec.groovy
package demo

import grails.testing.mixin.integration.Integration
import org.grails.datastore.mapping.multitenancy.exceptions.TenantNotFoundException
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.AuthorityUtils
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.test.annotation.Rollback
import spock.lang.Specification

@Integration
class CurrentUserTenantResolverSpec extends Specification {

    UserDataService userDataService
    RoleDataService roleDataService
    UserRoleDataService userRoleDataService

    @Autowired
    CurrentUserTenantResolver currentUserTenantResolver

    void "Test Current User throws a TenantNotFoundException if not logged in"() {
        when:
        currentUserTenantResolver.resolveTenantIdentifier()

        then:
        def e = thrown(TenantNotFoundException)
        e.message == "Tenant could not be resolved from Spring Security Principal"
    }

    void "Test current logged in user is resolved "() {
        given:
        Role role = roleDataService.saveByAuthority('ROLE_USER')
        User user = userDataService.save('admin', 'admin')
        userRoleDataService.save(user, role)

        when:
        loginAs('admin', 'ROLE_USER')
        Serializable username = currentUserTenantResolver.resolveTenantIdentifier()

        then:
        username == user.username

        when: "verify AllTenantsResolver::resolveTenantIds"
        Iterable<Serializable> tenantIds
        demo.User.withNewSession {
            tenantIds = currentUserTenantResolver.resolveTenantIds()
        }

        then:
        tenantIds.toList().size() == 1
        tenantIds.toList().get(0) == 'admin'

        cleanup:
        userRoleDataService.delete(user, role)
        roleDataService.delete(role)
        userDataService.delete(user.id)
    }

    void loginAs(String username, String authority) {
        User user = userDataService.findByUsername(username)
        if ( user ) {
            // have to be authenticated as an admin to create ACLs
            List<GrantedAuthority> authorityList = AuthorityUtils.createAuthorityList(authority)
            SecurityContextHolder.context.authentication = new UsernamePasswordAuthenticationToken(user.username,
                    user.password,
                    authorityList)
        }
    }


}

Next were are going to configure our app to use this custom tenant resolver.

Configure Multi-Tenancy by modifying application.yml.

grails-app/conf/application.yml
grails
    gorm:
        multiTenancy:
            mode: DISCRIMINATOR (1)
            tenantResolverClass: demo.CurrentUserTenantResolver  (2)
1 Define Multi-Tenancy mode as DISCRIMINATOR
2 Set the Tenant Resolver class with a custom class which you will write later in this guide.

3.7 Functional Test

Create a functional test which verifies that we can consume the API switching users.

src/integration-test/groovy/demo/PlanControllerSpec.groovy
package demo

import grails.gorm.multitenancy.Tenants
import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import spock.lang.Shared
import spock.lang.Specification
import spock.lang.IgnoreIf

@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
@Integration
class PlanControllerSpec extends Specification {
    PlanDataService planDataService
    UserDataService userDataService
    UserRoleDataService userRoleDataService
    UserService userService
    RoleDataService roleDataService

    @Shared
    HttpClient client

    @OnceBefore
    void init() {
        String baseUrl = "http://localhost:$serverPort"
        this.client  = HttpClient.create(baseUrl.toURL())
    }

    String accessToken(String u, String p) {
        HttpRequest request = HttpRequest.POST("/api/login", [username: u, password: p])
        HttpResponse<Map> resp = client.toBlocking().exchange(request, Map)
        if ( resp.status == HttpStatus.OK) {
            return resp.body().access_token
        }
        null
    }

    def "Plans for current logged user are retrieved"() {
        given:
        User vector = userService.save('vector', 'secret', 'ROLE_VILLAIN')
        User gru = userService.save('gru', 'secret', 'ROLE_VILLAIN')
        Tenants.withId("gru") { (1)
            planDataService.save('Steal the Moon')
        }
        Tenants.withId("vector") {
            planDataService.save('Steal a Pyramid')
        }

        when: 'login with the gru'
        String gruAccessToken = accessToken('gru', 'secret')

        then:
        gruAccessToken

        when:
        HttpRequest request = HttpRequest.GET("/plan").bearerAuth(gruAccessToken)
        HttpResponse<String> resp = client.toBlocking().exchange(request, String)

        then:
        resp.status == HttpStatus.OK
        resp.body() == '[{"title":"Steal the Moon"}]'

        when: 'login with the vector'
        String vectorAccessToken = accessToken('vector', 'secret')

        then:
        vectorAccessToken

        when:
        request = HttpRequest.GET("/plan").bearerAuth(vectorAccessToken)
        resp = client.toBlocking().exchange(request, String)

        then:
        resp.status == HttpStatus.OK
        resp.body() == '[{"title":"Steal a Pyramid"}]'

        cleanup:
        Tenants.withId("gru") { (1)
            planDataService.deleteByTitle('Steal the Moon')
        }
        Tenants.withId("vector") {
            planDataService.deleteByTitle('Steal the Pyramid')
        }
        userRoleDataService.deleteByUser(gru)
        userDataService.delete(gru.id)
        userRoleDataService.deleteByUser(vector)
        userDataService.delete(vector.id)
        roleDataService.deleteByAuthority('ROLE_VILLAIN')
    }
}
1 You can use a specific tenant id using the withId method

4 Run the tests

To run the tests:

./grailsw
grails> test-app
grails> open test-report

or

./gradlew check
open build/reports/tests/index.html

5 Help with Grails

Object Computing, Inc. (OCI) sponsored the creation of this Guide. A variety of consulting and support services are available.

OCI is Home to Grails

Meet the Team