├── images ├── cfn_deploy.png ├── cfn_outputs.png └── architecture.png ├── NOTICE ├── .github └── PULL_REQUEST_TEMPLATE.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── .gitignore ├── app ├── src │ ├── main │ │ ├── resources │ │ │ ├── META-INF │ │ │ │ └── spring.factories │ │ │ ├── logback-spring.xml │ │ │ ├── application.properties │ │ │ └── bootstrap.sql │ │ ├── java │ │ │ └── com │ │ │ │ └── amazon │ │ │ │ └── aws │ │ │ │ └── partners │ │ │ │ └── saasfactory │ │ │ │ └── pgrls │ │ │ │ ├── domain │ │ │ │ ├── Status.java │ │ │ │ ├── Tier.java │ │ │ │ ├── User.java │ │ │ │ └── Tenant.java │ │ │ │ ├── repository │ │ │ │ ├── UniqueRecordException.java │ │ │ │ ├── AdminDataSourceRepository.java │ │ │ │ ├── TenantAwareDataSource.java │ │ │ │ └── DataSourceRepository.java │ │ │ │ ├── UnauthorizedException.java │ │ │ │ ├── service │ │ │ │ ├── AdminService.java │ │ │ │ ├── TenantService.java │ │ │ │ ├── UserRowMapper.java │ │ │ │ ├── TenantRowMapper.java │ │ │ │ ├── AdminServiceImpl.java │ │ │ │ └── TenantServiceImpl.java │ │ │ │ ├── configuration │ │ │ │ ├── DataSourceCacheConfiguration.java │ │ │ │ ├── DataSourcePropertiesConfiguration.java │ │ │ │ ├── SecurityConfiguration.java │ │ │ │ ├── TenantAuthenticationProvider.java │ │ │ │ ├── TenantLogoutHandler.java │ │ │ │ └── DatabaseInit.java │ │ │ │ ├── SaaSFactoryPgRLS.java │ │ │ │ └── controller │ │ │ │ ├── RootController.java │ │ │ │ ├── AdminController.java │ │ │ │ └── TenantController.java │ │ └── webapp │ │ │ └── WEB-INF │ │ │ └── jsp │ │ │ ├── deleteUser.jsp │ │ │ ├── login.jsp │ │ │ ├── deleteTenant.jsp │ │ │ ├── index.jsp │ │ │ ├── admin.jsp │ │ │ ├── editTenant.jsp │ │ │ ├── editUser.jsp │ │ │ └── tenant.jsp │ └── test │ │ └── java │ │ └── com │ │ └── amazon │ │ └── aws │ │ └── partners │ │ └── saasfactory │ │ └── pgrls │ │ └── TenantTest.java ├── Dockerfile └── pom.xml ├── CONTRIBUTING.md ├── README.md └── cfn └── saas-factory-pg-rls.template /images/cfn_deploy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-factory-postgresql-rls/HEAD/images/cfn_deploy.png -------------------------------------------------------------------------------- /images/cfn_outputs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-factory-postgresql-rls/HEAD/images/cfn_outputs.png -------------------------------------------------------------------------------- /images/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-factory-postgresql-rls/HEAD/images/architecture.png -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | AWS SaaS Factory Multi-Tenant Data Isolation Using PostgreSQL Row Level Security 2 | Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | target 3 | dependency-reduced-pom.xml 4 | .idea 5 | *.iml 6 | *.ipr 7 | *.iws 8 | 9 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 10 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 11 | 12 | # User-specific stuff 13 | .idea/**/workspace.xml 14 | .idea/**/tasks.xml 15 | .idea/**/usage.statistics.xml 16 | .idea/**/dictionaries 17 | .idea/**/shelf 18 | 19 | # Generated files 20 | .idea/**/contentModel.xml 21 | 22 | # Sensitive or high-churn files 23 | .idea/**/dataSources/ 24 | .idea/**/dataSources.ids 25 | .idea/**/dataSources.local.xml 26 | .idea/**/sqlDataSources.xml 27 | .idea/**/dynamic.xml 28 | .idea/**/uiDesigner.xml 29 | .idea/**/dbnavigator.xml 30 | 31 | # Gradle 32 | .idea/**/gradle.xml 33 | .idea/**/libraries 34 | 35 | # Gradle and Maven with auto-import 36 | # When using Gradle or Maven with auto-import, you should exclude module files, 37 | # since they will be recreated, and may cause churn. Uncomment if using 38 | # auto-import. 39 | .idea/modules.xml 40 | .idea/*.iml 41 | .idea/modules 42 | *.iml 43 | *.ipr 44 | 45 | -------------------------------------------------------------------------------- /app/src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | # Application listener to bootstrap the database before the application runs 17 | org.springframework.context.ApplicationListener=com.amazon.aws.partners.saasfactory.pgrls.configuration.DatabaseInit -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/domain/Status.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.domain; 18 | 19 | /** 20 | * @author mibeard 21 | */ 22 | public enum Status { 23 | Active, Suspended; 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/domain/Tier.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.domain; 18 | 19 | /** 20 | * @author mibeard 21 | */ 22 | public enum Tier { 23 | Gold, Silver, Bronze; 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/repository/UniqueRecordException.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.repository; 18 | 19 | public class UniqueRecordException extends RuntimeException { 20 | 21 | public UniqueRecordException(String msg) { 22 | super(msg); 23 | } 24 | 25 | public UniqueRecordException(String msg, Throwable cause) { 26 | super(msg, cause); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/UnauthorizedException.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls; 18 | 19 | import org.springframework.http.HttpStatus; 20 | import org.springframework.web.bind.annotation.ResponseStatus; 21 | 22 | /** 23 | * When thrown, Spring will return an HTTP 401 24 | * @author mibeard 25 | */ 26 | @ResponseStatus(HttpStatus.UNAUTHORIZED) 27 | public class UnauthorizedException extends RuntimeException { 28 | } 29 | -------------------------------------------------------------------------------- /app/src/main/resources/logback-spring.xml: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 29 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | FROM public.ecr.aws/docker/library/amazoncorretto:11-alpine-jdk AS build 17 | RUN ["/usr/lib/jvm/default-jvm/bin/jlink", "--compress=2", "--no-man-pages", "--module-path", "/usr/lib/jvm/default-jvm/jmods", "--add-modules", "java.base,java.logging,java.xml,jdk.unsupported,java.sql,java.sql.rowset,java.naming,java.desktop,java.management,java.security.jgss,java.instrument,java.net.http", "--output", "/jdk-mini"] 18 | 19 | FROM public.ecr.aws/docker/library/alpine:latest 20 | COPY --from=build /jdk-mini /opt/jdk/ 21 | ENV PATH=$PATH:/opt/jdk/bin 22 | COPY ./target/SaaSFactoryPgRLS.war /usr/src/app/app.war 23 | ENTRYPOINT ["java", "-jar", "/usr/src/app/app.war"] 24 | EXPOSE 8080/tcp 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/service/AdminService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.service; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 20 | 21 | import java.util.List; 22 | import java.util.UUID; 23 | 24 | /** 25 | * Simplistic CRUD API 26 | * @author mibeard 27 | */ 28 | public interface AdminService { 29 | 30 | public Tenant saveTenant(Tenant tenant); 31 | 32 | public List getTenants(); 33 | 34 | public Tenant getTenant(UUID tenantId); 35 | 36 | public void deleteTenant(Tenant tenant); 37 | 38 | public boolean tenantExists(UUID tenantId); 39 | 40 | public boolean userExists(UUID userId); 41 | 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/service/TenantService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.service; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 20 | import com.amazon.aws.partners.saasfactory.pgrls.domain.User; 21 | 22 | import java.util.List; 23 | import java.util.UUID; 24 | 25 | /** 26 | * Simplistic CRUD API 27 | * @author mibeard 28 | */ 29 | public interface TenantService { 30 | 31 | public Tenant getTenant(UUID tenantId); 32 | 33 | public Tenant saveTenant(Tenant tenant); 34 | 35 | public List getUsers(Tenant tenant); 36 | 37 | public User saveUser(User user); 38 | 39 | public User getUser(UUID userId); 40 | 41 | public void deleteUser(User user); 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/configuration/DataSourceCacheConfiguration.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.configuration; 18 | 19 | import java.util.Map; 20 | import java.util.concurrent.ConcurrentHashMap; 21 | 22 | import com.amazon.aws.partners.saasfactory.pgrls.repository.DataSourceRepository; 23 | import org.springframework.context.annotation.Bean; 24 | import org.springframework.context.annotation.Configuration; 25 | 26 | /** 27 | * Creates a singleton (shared Spring application wide) cache of active 28 | * tenants and their data source connection pools. 29 | * @author mibeard 30 | * @see DataSourceRepository 31 | */ 32 | @Configuration 33 | public class DataSourceCacheConfiguration { 34 | 35 | @Bean 36 | public Map dataSourceTargets() { 37 | ConcurrentHashMap targets = new ConcurrentHashMap<>(); 38 | return targets; 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /app/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | debug = false 17 | 18 | spring.profiles.active = default 19 | 20 | spring.mvc.view.prefix = /WEB-INF/jsp/ 21 | spring.mvc.view.suffix = .jsp 22 | 23 | logging.level.org.springframework.jdbc.core = TRACE 24 | logging.level.org.springframework.jdbc.core.JdbcTemplate = DEBUG 25 | logging.level.org.springframework.jdbc.core.StatementCreatorUtils = DEBUG 26 | 27 | #logging.level.org.springframework.security=DEBUG 28 | #logging.level.org.springframework.security.web.FilterChainProxy=DEBUG 29 | 30 | spring.datasource.type = com.zaxxer.hikari.HikariDataSource 31 | spring.datasource.url = jdbc:postgresql://${DB_HOST}/${DB_NAME} 32 | spring.datasource.username = ${DB_USER} 33 | spring.datasource.password = ${DB_PASS} 34 | 35 | admin.datasource.type = com.zaxxer.hikari.HikariDataSource 36 | admin.datasource.url = jdbc:postgresql://${DB_HOST}/${DB_NAME} 37 | admin.datasource.username = ${DB_ADMIN_USER} 38 | admin.datasource.password = ${DB_ADMIN_PASS} -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/SaaSFactoryPgRLS.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls; 18 | 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | import org.springframework.boot.Banner; 22 | import org.springframework.boot.WebApplicationType; 23 | import org.springframework.boot.autoconfigure.SpringBootApplication; 24 | import org.springframework.boot.builder.SpringApplicationBuilder; 25 | import org.springframework.context.ConfigurableApplicationContext; 26 | 27 | @SpringBootApplication 28 | public class SaaSFactoryPgRLS { 29 | 30 | private static final Logger LOGGER = LoggerFactory.getLogger(SaaSFactoryPgRLS.class); 31 | 32 | public static void main(String[] args) throws Exception { 33 | SpringApplicationBuilder app = new SpringApplicationBuilder(SaaSFactoryPgRLS.class) 34 | .web(WebApplicationType.SERVLET) 35 | .bannerMode(Banner.Mode.OFF); 36 | ConfigurableApplicationContext ctx = app.run(); 37 | } 38 | 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/service/UserRowMapper.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.service; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 20 | import com.amazon.aws.partners.saasfactory.pgrls.domain.User; 21 | import org.springframework.jdbc.core.RowMapper; 22 | 23 | import java.sql.ResultSet; 24 | import java.sql.SQLException; 25 | import java.util.UUID; 26 | 27 | /** 28 | * @author mibeard 29 | */ 30 | public class UserRowMapper implements RowMapper { 31 | 32 | public User mapRow(ResultSet result, int rowNumber) throws SQLException { 33 | User user = new User(); 34 | user.setId(result.getObject("user_id", UUID.class)); 35 | user.setEmail(result.getString("email")); 36 | user.setFamilyName(result.getString("family_name")); 37 | user.setGivenName(result.getString("given_name")); 38 | Tenant tenant = new Tenant(); 39 | tenant.setId(result.getObject("tenant_id", UUID.class)); 40 | user.setTenant(tenant); 41 | return user; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/service/TenantRowMapper.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.service; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Status; 20 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 21 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tier; 22 | import org.springframework.jdbc.core.RowMapper; 23 | 24 | import java.sql.ResultSet; 25 | import java.sql.SQLException; 26 | import java.util.UUID; 27 | 28 | /** 29 | * @author mibeard 30 | */ 31 | public class TenantRowMapper implements RowMapper { 32 | 33 | public Tenant mapRow(ResultSet result, int rowNumber) throws SQLException { 34 | Tenant tenant = new Tenant(); 35 | tenant.setId(result.getObject("tenant_id", UUID.class)); 36 | tenant.setName(result.getString("name")); 37 | String s = result.getString("status"); 38 | if (s != null) { 39 | tenant.setStatus(Status.valueOf(s)); 40 | } 41 | String t = result.getString("tier"); 42 | if (t != null) { 43 | tenant.setTier(Tier.valueOf(t)); 44 | } 45 | return tenant; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/domain/User.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.domain; 18 | 19 | import java.util.UUID; 20 | 21 | /** 22 | * @author mibeard 23 | */ 24 | public class User { 25 | 26 | private UUID id; 27 | private Tenant tenant; 28 | private String email; 29 | private String givenName; 30 | private String familyName; 31 | 32 | public User() { 33 | this(null); 34 | } 35 | 36 | public User(UUID id) { 37 | this.id = id; 38 | } 39 | 40 | public UUID getId() { 41 | return id; 42 | } 43 | 44 | public void setId(UUID id) { 45 | this.id = id; 46 | } 47 | 48 | public Tenant getTenant() { 49 | return tenant; 50 | } 51 | 52 | public void setTenant(Tenant tenant) { 53 | this.tenant = tenant; 54 | } 55 | 56 | public String getEmail() { 57 | return email; 58 | } 59 | 60 | public void setEmail(String email) { 61 | this.email = email; 62 | } 63 | 64 | public String getGivenName() { 65 | return givenName; 66 | } 67 | 68 | public void setGivenName(String givenName) { 69 | this.givenName = givenName; 70 | } 71 | 72 | public String getFamilyName() { 73 | return familyName; 74 | } 75 | 76 | public void setFamilyName(String familyName) { 77 | this.familyName = familyName; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/test/java/com/amazon/aws/partners/saasfactory/pgrls/TenantTest.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Status; 20 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 21 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tier; 22 | import org.junit.Test; 23 | 24 | import java.util.ArrayList; 25 | import java.util.UUID; 26 | 27 | import static org.junit.Assert.*; 28 | 29 | public class TenantTest { 30 | 31 | @Test 32 | public void isLightweight() { 33 | Tenant lightweight = new Tenant(); 34 | lightweight.setId(UUID.randomUUID()); 35 | assertTrue("Only ID is lightweight", lightweight.isLightweight()); 36 | 37 | lightweight.setUsers(new ArrayList<>()); 38 | assertTrue("Only ID and empty users list is still lightweight", lightweight.isLightweight()); 39 | 40 | Tenant heavyweight = new Tenant(); 41 | heavyweight.setId(UUID.randomUUID()); 42 | heavyweight.setName("ABCDEF"); 43 | 44 | assertFalse("Any property in addition to ID are not lightweight", heavyweight.isLightweight()); 45 | 46 | heavyweight.setStatus(Status.Active); 47 | heavyweight.setTier(Tier.Gold); 48 | heavyweight.setUsers(new ArrayList<>()); 49 | assertFalse("Fully hydrated tenants are not lightweight", heavyweight.isLightweight()); 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/repository/AdminDataSourceRepository.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.repository; 18 | 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | import org.springframework.beans.factory.annotation.Autowired; 22 | import org.springframework.beans.factory.annotation.Qualifier; 23 | import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; 24 | import org.springframework.context.annotation.Configuration; 25 | import org.springframework.stereotype.Repository; 26 | 27 | import javax.sql.DataSource; 28 | 29 | /** 30 | * Generates JDBC data source pool using the table OWNER credentials. 31 | * This connection will not be bound by RLS policies in order to 32 | * perform INSERTs and other actions regardless of the current 33 | * tenant context. 34 | * @author mibeard 35 | */ 36 | @Repository 37 | @Configuration 38 | public class AdminDataSourceRepository { 39 | 40 | private static final Logger LOGGER = LoggerFactory.getLogger(AdminDataSourceRepository.class); 41 | 42 | // See DataSourcePropertiesConfiguration 43 | @Autowired 44 | @Qualifier("adminDataSourceProperties") 45 | DataSourceProperties adminDataSourceProperties; 46 | 47 | public DataSource dataSource() { 48 | return adminDataSourceProperties.initializeDataSourceBuilder().build(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/configuration/DataSourcePropertiesConfiguration.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.configuration; 18 | 19 | import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; 20 | import org.springframework.boot.context.properties.ConfigurationProperties; 21 | import org.springframework.context.annotation.Bean; 22 | import org.springframework.context.annotation.Configuration; 23 | import org.springframework.context.annotation.Primary; 24 | 25 | /** 26 | * Spring will generate these database properties automagically 27 | * from application.properties which is populated via environment 28 | * variables. We have 2 different sets of properties. One for the 29 | * application to connect as -- and be constrained by RLS policies. 30 | * The 2nd is to connect as the database owner which will circumvent 31 | * RLS to allow for administrative commands. 32 | * @author mibeard 33 | */ 34 | @Configuration 35 | public class DataSourcePropertiesConfiguration { 36 | 37 | @Bean 38 | @Primary 39 | @ConfigurationProperties(prefix = "spring.datasource") 40 | public DataSourceProperties dataSourceProperties() { 41 | return new DataSourceProperties(); 42 | } 43 | 44 | @Bean 45 | @ConfigurationProperties(prefix = "admin.datasource") 46 | public DataSourceProperties adminDataSourceProperties() { 47 | return new DataSourceProperties(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/controller/RootController.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.controller; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.service.AdminService; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | import org.springframework.beans.factory.annotation.Autowired; 23 | import org.springframework.http.ResponseEntity; 24 | import org.springframework.stereotype.Controller; 25 | import org.springframework.ui.Model; 26 | import org.springframework.web.bind.annotation.GetMapping; 27 | import org.springframework.web.bind.annotation.RequestMapping; 28 | 29 | @Controller 30 | public class RootController { 31 | 32 | private final static Logger LOGGER = LoggerFactory.getLogger(RootController.class); 33 | 34 | @Autowired 35 | private AdminService adminService; 36 | 37 | @GetMapping({"/", "/index.html"}) 38 | public String index(Model model) { 39 | return "index"; 40 | } 41 | 42 | /** 43 | * Endpoint for the ALB to call. Simply returns an HTTP 200. 44 | * @return 45 | */ 46 | @GetMapping("/health") 47 | public ResponseEntity health() { 48 | return ResponseEntity.ok().build(); 49 | } 50 | 51 | @RequestMapping("/login") 52 | public String login(Model model) { 53 | model.addAttribute("tenants", adminService.getTenants()); 54 | return "login"; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/src/main/webapp/WEB-INF/jsp/deleteUser.jsp: -------------------------------------------------------------------------------- 1 | 2 | 18 | <%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %> 19 | <%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> 20 | <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> 21 | 22 | 23 | Delete User 24 | 25 | 26 | 27 | 34 |
35 |

Are you sure?

36 | 37 |
38 |
39 | 43 |
44 |
45 |
46 | 47 | 48 |
49 | 50 | 51 |
52 |
53 |
54 | Cancel 55 | 56 |
57 |
58 |
59 |
60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/src/main/webapp/WEB-INF/jsp/login.jsp: -------------------------------------------------------------------------------- 1 | 2 | 18 | <%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> 19 | <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> 20 | <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> 21 | 22 | 23 | AWS SaaS Factory PostgreSQL Row Level Security 24 | 25 | 26 | 27 |
28 |

Login to Tenant Management

29 |

Choose the tenant you'd like to "authenticate" as to mange tenant users.

30 |
31 | 32 | 33 | 34 |
35 |
36 | 42 |
43 |
44 |
45 |
46 | Cancel 47 | 48 |
49 |
50 |
51 |
52 | 53 | 54 | 64 | 65 | -------------------------------------------------------------------------------- /app/src/main/webapp/WEB-INF/jsp/deleteTenant.jsp: -------------------------------------------------------------------------------- 1 | 2 | 18 | <%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %> 19 | <%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> 20 | <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> 21 | 22 | 23 | Delete Tenant 24 | 25 | 26 | 27 | 34 |
35 |

Are you sure?

36 | 37 |
38 |
39 | 43 |
44 |
45 |
46 | 47 | 48 |
49 | 50 | 51 |
52 |
53 |
54 | Cancel 55 | 56 |
57 |
58 |
59 |
60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/src/main/webapp/WEB-INF/jsp/index.jsp: -------------------------------------------------------------------------------- 1 | 2 | 18 | <%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> 19 | <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> 20 | <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> 21 | 22 | 23 | AWS SaaS Factory PostgreSQL Row Level Security 24 | 25 | 26 | 27 |
28 |

AWS SaaS Factory PostgreSQL Row Level Security Demo

29 |

Use this interface to test the example RLS policies described in the 30 | Multi-tenant 31 | data isolation with PostgreSQL Row Level Security blog post.
32 | Please note this sample is for demonstration purposes only.

33 |

As the SaaS administrator, you can add new tenants to the database. Once you've added a tenant, that tenant can manage their users.
34 | Instructions: 35 |

    36 |
  1. Add at least two tenants to the system as the administrator.
  2. 37 |
  3. Add at one or more users to each tenant as that tenant.
  4. 38 |
  5. Now try to view or edit users of another tenant and you'll see the RLS policies enforcing isolation of the tenant data.
  6. 39 |
40 |

41 |
42 |
43 |

Admin Tenant Management

44 |

These actions will be executed as the SaaS administrator.

45 |
46 |
47 |

Tenant User Management

48 |

These actions will be executed in the context of the Tenant you choose to "login" as and will be secured by RLS policies.

49 |
50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/domain/Tenant.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.domain; 18 | 19 | import java.util.ArrayList; 20 | import java.util.List; 21 | import java.util.UUID; 22 | 23 | /** 24 | * @author mibeard 25 | */ 26 | public class Tenant { 27 | 28 | private UUID id; 29 | private String name; 30 | private Tier tier; 31 | private Status status; 32 | private List users = new ArrayList<>(); 33 | 34 | public Tenant() { 35 | this(null); 36 | } 37 | 38 | public Tenant(UUID id) { 39 | this.id = id; 40 | } 41 | 42 | public UUID getId() { 43 | return id; 44 | } 45 | 46 | public String getIdAsString() { 47 | return id != null ? id.toString() : null; 48 | } 49 | 50 | public void setId(UUID id) { 51 | this.id = id; 52 | } 53 | 54 | public void setId(String id) { 55 | this.id = UUID.fromString(id); 56 | } 57 | 58 | public String getName() { 59 | return name; 60 | } 61 | 62 | public void setName(String name) { 63 | this.name = name; 64 | } 65 | 66 | public Tier getTier() { 67 | return tier; 68 | } 69 | 70 | public String getTierAsString() { 71 | return tier != null ? tier.toString() : null; 72 | } 73 | 74 | public void setTier(Tier tier) { 75 | this.tier = tier; 76 | } 77 | 78 | public Status getStatus() { 79 | return status; 80 | } 81 | 82 | public String getStatusAsString() { 83 | return status != null ? status.toString() : null; 84 | } 85 | 86 | public void setStatus(Status status) { 87 | this.status = status; 88 | } 89 | 90 | public List getUsers() { 91 | return users; 92 | } 93 | 94 | public void setUsers(List users) { 95 | this.users = users != null ? users : new ArrayList<>(); 96 | } 97 | 98 | @Override 99 | public String toString() { 100 | return getName(); 101 | } 102 | 103 | /** 104 | * Returns true if only the only property set is the id 105 | * @return 106 | */ 107 | public boolean isLightweight() { 108 | return ( 109 | id != null 110 | && (name == null || name.isEmpty()) 111 | && tier == null 112 | && status == null 113 | && (users == null || users.isEmpty()) 114 | ); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/configuration/SecurityConfiguration.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.configuration; 18 | 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.context.annotation.Bean; 21 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 22 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 23 | import org.springframework.security.config.annotation.web.builders.WebSecurity; 24 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 25 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 26 | import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; 27 | 28 | @EnableWebSecurity 29 | public class SecurityConfiguration extends WebSecurityConfigurerAdapter { 30 | 31 | @Autowired 32 | private TenantAuthenticationProvider authenticationProvider; 33 | 34 | @Override 35 | protected void configure(AuthenticationManagerBuilder auth) throws Exception { 36 | auth.authenticationProvider(authenticationProvider); 37 | } 38 | 39 | @Override 40 | public void configure(WebSecurity web) throws Exception { 41 | web.ignoring().antMatchers("/webjars/**"); 42 | } 43 | 44 | @Bean 45 | public LogoutSuccessHandler logoutSuccessHandler() { 46 | return new TenantLogoutHandler(); 47 | } 48 | 49 | @Override 50 | public void configure(HttpSecurity http) throws Exception { 51 | http.authorizeRequests() 52 | .antMatchers("/", "/health", "/admin/**").permitAll() // no auth 53 | .antMatchers("/tenant/**").authenticated() // tenant user management is authenticated 54 | .and() // custom login form 55 | .formLogin() 56 | .loginPage("/login") 57 | .permitAll() // anyone can access login 58 | .and() // custom logout redirect 59 | .logout() 60 | .logoutSuccessHandler(logoutSuccessHandler()) 61 | .permitAll(); // anyone can access logout 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/configuration/TenantAuthenticationProvider.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.configuration; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 20 | import com.amazon.aws.partners.saasfactory.pgrls.service.AdminService; 21 | import org.slf4j.Logger; 22 | import org.slf4j.LoggerFactory; 23 | import org.springframework.beans.factory.annotation.Autowired; 24 | import org.springframework.security.authentication.AuthenticationProvider; 25 | import org.springframework.security.authentication.AuthenticationServiceException; 26 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 27 | import org.springframework.security.core.Authentication; 28 | import org.springframework.security.core.AuthenticationException; 29 | import org.springframework.stereotype.Component; 30 | 31 | import java.util.ArrayList; 32 | import java.util.UUID; 33 | 34 | @Component 35 | public class TenantAuthenticationProvider implements AuthenticationProvider { 36 | 37 | private final static Logger LOGGER = LoggerFactory.getLogger(TenantAuthenticationProvider.class); 38 | 39 | @Autowired 40 | AdminService adminService; 41 | 42 | // Not a real auth experience here, but lets us create a session which we can use to bind 43 | // tenant context to our HTTP requests 44 | @Override 45 | public Authentication authenticate(Authentication authentication) throws AuthenticationException { 46 | String name = authentication.getName(); 47 | String password = authentication.getCredentials().toString(); 48 | UsernamePasswordAuthenticationToken token = null; 49 | try { 50 | Tenant principal = adminService.getTenant(UUID.fromString(password)); 51 | token = new UsernamePasswordAuthenticationToken(principal, principal.getId(), new ArrayList<>()); 52 | } catch (Exception e) { 53 | LOGGER.error("Error authenticating", e); 54 | throw new AuthenticationServiceException("Error authenticating"); 55 | } 56 | return token; 57 | } 58 | 59 | @Override 60 | public boolean supports(Class aClass) { 61 | return aClass.equals(UsernamePasswordAuthenticationToken.class); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/resources/bootstrap.sql: -------------------------------------------------------------------------------- 1 | -- Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -- 3 | -- Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | -- software and associated documentation files (the "Software"), to deal in the Software 5 | -- without restriction, including without limitation the rights to use, copy, modify, 6 | -- merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | -- permit persons to whom the Software is furnished to do so. 8 | -- 9 | -- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | -- INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | -- PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | -- HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | -- OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | -- SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -- Load up the UUID data type 17 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 18 | 19 | -- Create a login role for the application to connect as so it is not connecting 20 | -- as the master user and so that it is not the owner of the tables 21 | DO $$ 22 | BEGIN 23 | IF NOT EXISTS(SELECT * FROM pg_roles WHERE rolname = '{{DB_APP_USER}}') THEN 24 | CREATE USER {{DB_APP_USER}} WITH LOGIN PASSWORD '{{DB_APP_PASS}}'; 25 | GRANT USAGE ON SCHEMA public TO {{DB_APP_USER}}; 26 | GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {{DB_APP_USER}}; 27 | ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO {{DB_APP_USER}}; 28 | GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO {{DB_APP_USER}}; 29 | ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE ON SEQUENCES TO {{DB_APP_USER}}; 30 | END IF; 31 | END 32 | $$ 33 | 34 | -- Create a table for our tenants with indexes on the primary key and the tenant’s name 35 | CREATE TABLE IF NOT EXISTS tenant ( 36 | tenant_id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 37 | name VARCHAR(255) UNIQUE, 38 | status VARCHAR(64) CHECK (status IN ('Active', 'Suspended')), 39 | tier VARCHAR(64) CHECK (tier IN ('Gold', 'Silver', 'Bronze')) 40 | ); 41 | 42 | -- Turn on RLS 43 | ALTER TABLE tenant ENABLE ROW LEVEL SECURITY; 44 | 45 | -- Restrict read and write actions so tenants can only see their rows 46 | -- cast the UUID value in tenant_id to match the type current_user returns 47 | DO $$ 48 | BEGIN 49 | IF NOT EXISTS (SELECT * FROM pg_policies WHERE tablename = 'tenant' AND policyname = 'tenant_isolation_policy') THEN 50 | CREATE POLICY tenant_isolation_policy ON tenant 51 | USING (tenant_id = current_setting('app.current_tenant')::UUID); 52 | END IF; 53 | END 54 | $$ 55 | 56 | -- Create a table for users of a tenant 57 | CREATE TABLE IF NOT EXISTS tenant_user ( 58 | user_id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, 59 | tenant_id UUID NOT NULL REFERENCES tenant (tenant_id) ON DELETE RESTRICT, 60 | email VARCHAR(255) NOT NULL UNIQUE, 61 | given_name VARCHAR(255) NOT NULL CHECK (given_name <> ''), 62 | family_name VARCHAR(255) NOT NULL CHECK (family_name <> '') 63 | ); 64 | 65 | -- And apply RLS for the tenant users as we did for tenants 66 | ALTER TABLE tenant_user ENABLE ROW LEVEL SECURITY; 67 | 68 | DO $$ 69 | BEGIN 70 | IF NOT EXISTS(SELECT * FROM pg_policies WHERE tablename = 'tenant_user' AND policyname = 'tenant_user_isolation_policy') THEN 71 | CREATE POLICY tenant_user_isolation_policy ON tenant_user 72 | USING (tenant_id = current_setting('app.current_tenant')::UUID); 73 | END IF; 74 | END 75 | $$ 76 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check [existing open](https://github.com/aws-samples/aws-saas-factory-postgresql-rls/issues), or [recently closed](https://github.com/aws-samples/aws-saas-factory-postgresql-rls/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-samples/aws-saas-factory-postgresql-rls/labels/help%20wanted) issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](https://github.com/aws-samples/aws-saas-factory-postgresql-rls/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/configuration/TenantLogoutHandler.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.configuration; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 20 | import com.amazon.aws.partners.saasfactory.pgrls.repository.DataSourceRepository; 21 | import com.zaxxer.hikari.HikariDataSource; 22 | import org.slf4j.Logger; 23 | import org.slf4j.LoggerFactory; 24 | import org.springframework.beans.BeansException; 25 | import org.springframework.context.ApplicationContext; 26 | import org.springframework.context.ApplicationContextAware; 27 | import org.springframework.security.core.Authentication; 28 | import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; 29 | import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; 30 | 31 | import javax.servlet.ServletException; 32 | import javax.servlet.http.HttpServletRequest; 33 | import javax.servlet.http.HttpServletResponse; 34 | import javax.sql.DataSource; 35 | import java.io.IOException; 36 | import java.util.UUID; 37 | 38 | public class TenantLogoutHandler extends SimpleUrlLogoutSuccessHandler implements LogoutSuccessHandler, ApplicationContextAware { 39 | 40 | private final static Logger LOGGER = LoggerFactory.getLogger(TenantLogoutHandler.class); 41 | 42 | private DataSourceRepository databaseConnectionPools; 43 | 44 | // Custom clean up of the database connection pool when we logout a tenant 45 | @Override 46 | public void onLogoutSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException { 47 | UUID tenantId = ((Tenant) authentication.getPrincipal()).getId(); 48 | LOGGER.info("Tenant logout: removing database connection pool for tenant {}", tenantId); 49 | // Can't just call databaseConnectionPools.dataSource() because we just logged out the security context 50 | // principal and we'll get a null pointer when it tries to resolve the pool from it's target map. 51 | DataSource connectionPool = (DataSource) databaseConnectionPools.getDataSourceTargets().get(tenantId); 52 | // Explicitly close down the connection pool 53 | if (connectionPool != null) { 54 | ((HikariDataSource) connectionPool).close(); 55 | } 56 | // And remove it from the list of targets 57 | databaseConnectionPools.getDataSourceTargets().remove(tenantId); 58 | super.onLogoutSuccess(httpServletRequest, httpServletResponse, authentication); 59 | } 60 | 61 | @Override 62 | public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { 63 | databaseConnectionPools = applicationContext.getBean("dataSourceRepository", DataSourceRepository.class); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/repository/TenantAwareDataSource.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.repository; 18 | 19 | import java.sql.Connection; 20 | import java.sql.SQLException; 21 | import java.sql.Statement; 22 | 23 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 24 | import org.slf4j.Logger; 25 | import org.slf4j.LoggerFactory; 26 | import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; 27 | import org.springframework.security.authentication.AnonymousAuthenticationToken; 28 | import org.springframework.security.core.Authentication; 29 | import org.springframework.security.core.context.SecurityContextHolder; 30 | 31 | /** 32 | * This is the single place where RLS policies in the database are tied to the application. We are using 33 | * a connection session variable to tell PostgreSQL what the current tenant context is for that connection. 34 | * Connections are not shared and session variables are private so this is thread safe. If we did not use 35 | * a session variable, you'd have to create a Postgres login ROLE for each tenant and then maintain a lookup 36 | * mechanism to get the proper connection credentials for each tenant. 37 | * @author mibeard 38 | */ 39 | public class TenantAwareDataSource extends AbstractRoutingDataSource { 40 | 41 | private static final Logger LOGGER = LoggerFactory.getLogger(TenantAwareDataSource.class); 42 | 43 | @Override 44 | protected Object determineCurrentLookupKey() { 45 | Object key = null; 46 | // Pull the currently authenticated tenant from the security context 47 | // of the HTTP request and use it as the key in the map that points 48 | // to the connection pool (data source) for each tenant. 49 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 50 | try { 51 | if (!(authentication instanceof AnonymousAuthenticationToken)) { 52 | Tenant currentTenant = (Tenant) authentication.getPrincipal(); 53 | key = currentTenant.getId(); 54 | } 55 | } catch (Exception e) { 56 | LOGGER.error("Failed to get current tenant for data source lookup", e); 57 | throw new RuntimeException(e); 58 | } 59 | return key; 60 | } 61 | 62 | @Override 63 | public Connection getConnection() throws SQLException { 64 | // Every time the app asks the data source for a connection 65 | // set the PostgreSQL session variable to the current tenant 66 | // to enforce data isolation. 67 | Connection connection = super.getConnection(); 68 | try (Statement sql = connection.createStatement()) { 69 | LOGGER.info("Setting PostgreSQL session variable app.current_tenant = '{}' on {}", determineCurrentLookupKey().toString(), this); 70 | sql.execute("SET SESSION app.current_tenant = '" + determineCurrentLookupKey().toString() + "'"); 71 | } catch (Exception e) { 72 | LOGGER.error("Failed to execute: SET SESSION app.current_tenant = '{}'", determineCurrentLookupKey().toString(), e); 73 | } 74 | return connection; 75 | } 76 | 77 | @Override 78 | public String toString() { 79 | return determineTargetDataSource().toString(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/webapp/WEB-INF/jsp/admin.jsp: -------------------------------------------------------------------------------- 1 | 2 | 18 | <%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> 19 | <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> 20 | <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> 21 | 22 | 23 | AWS SaaS Factory PostgreSQL Row Level Security 24 | 25 | 26 | 27 | 33 |
34 |

Admin Tenant Management

35 |

These actions will be executed as the SaaS administrator and not restricted by RLS policies.

36 |

 

37 | 38 |
39 |
40 | 44 |
45 |
46 |
47 |
48 |
49 |

Tenants

50 |
51 |
52 | Add Tenant 53 |
54 |
55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 70 | 71 | 72 | 73 | 74 | 75 |
IDStatusTierName
${tenant.id} 69 | ${tenant.status}${tenant.tier}${tenant.name}
76 |
77 |
78 | 79 | 80 | 87 | 88 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/repository/DataSourceRepository.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.repository; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 20 | import org.slf4j.Logger; 21 | import org.slf4j.LoggerFactory; 22 | import org.springframework.beans.factory.annotation.Autowired; 23 | import org.springframework.beans.factory.annotation.Qualifier; 24 | import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; 25 | import org.springframework.context.annotation.Configuration; 26 | import org.springframework.security.authentication.AnonymousAuthenticationToken; 27 | import org.springframework.security.core.Authentication; 28 | import org.springframework.security.core.context.SecurityContextHolder; 29 | import org.springframework.stereotype.Repository; 30 | 31 | import java.util.Map; 32 | 33 | /** 34 | * Generates a JDBC connection pool per authenticated tenant. These connections will be constrained by 35 | * RLS policies to prevent cross tenant data access. 36 | * 37 | * Most systems have multiple users per tenant. These connection pools are per tenant, not user. 38 | * @author mibeard 39 | */ 40 | @Repository 41 | @Configuration 42 | public class DataSourceRepository { 43 | 44 | private static final Logger LOGGER = LoggerFactory.getLogger(DataSourceRepository.class); 45 | 46 | // See DataSourcePropertiesConfiguration 47 | @Autowired 48 | @Qualifier("dataSourceProperties") 49 | private DataSourceProperties dataSourceProperties; 50 | 51 | // See DataSourceCacheConfiguration 52 | @Autowired 53 | private Map dataSourceTargets; 54 | 55 | private final TenantAwareDataSource dataSource = new TenantAwareDataSource(); 56 | 57 | public javax.sql.DataSource dataSource() { 58 | Object currentTenant = null; 59 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 60 | if (authentication != null && !(authentication instanceof AnonymousAuthenticationToken)) { 61 | currentTenant = ((Tenant) authentication.getPrincipal()).getId(); 62 | } 63 | if (currentTenant == null) { 64 | throw new RuntimeException("Can't return data source. No authenticated tenant."); 65 | } 66 | 67 | // Each tenant gets its own Hikari connection pool 68 | if (!dataSourceTargets.containsKey(currentTenant) || dataSourceTargets.get(currentTenant) == null) { 69 | LOGGER.info("Creating new connection pool for tenant {}", currentTenant); 70 | dataSourceTargets.put(currentTenant, dataSourceProperties.initializeDataSourceBuilder().build()); 71 | } 72 | 73 | // Tell our data source router where to find all the keys we want it to map pools to 74 | dataSource.setTargetDataSources(dataSourceTargets); 75 | 76 | // Tell Spring we're done configuring the data source so it can initialize the routing 77 | // functionality. We must call this each time, because internally AbstractRoutingDataSource 78 | // is keeping a map of resolved data sources per key and we may have just added a new 79 | // tenant to our list of targets or we may have removed (logged out) a tenant and want 80 | // to cleanup. 81 | dataSource.afterPropertiesSet(); 82 | 83 | LOGGER.info("Returning dataSource with targets:"); 84 | dataSourceTargets.keySet().forEach((key) -> { 85 | LOGGER.info(String.valueOf(key)); 86 | }); 87 | 88 | return dataSource; 89 | } 90 | 91 | public Map getDataSourceTargets() { 92 | return dataSourceTargets; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/configuration/DatabaseInit.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.configuration; 18 | 19 | import org.slf4j.Logger; 20 | import org.slf4j.LoggerFactory; 21 | import org.springframework.boot.context.event.ApplicationContextInitializedEvent; 22 | import org.springframework.context.ApplicationListener; 23 | import org.springframework.core.env.Environment; 24 | import org.springframework.stereotype.Component; 25 | 26 | import java.io.IOException; 27 | import java.io.InputStream; 28 | import java.sql.*; 29 | import java.util.Properties; 30 | import java.util.Scanner; 31 | 32 | @Component 33 | public class DatabaseInit implements ApplicationListener { 34 | 35 | private static final Logger LOGGER = LoggerFactory.getLogger(DatabaseInit.class); 36 | 37 | // Execute this database bootstrap sequence after Spring has initialized its 38 | // context but before any Beans (data sources) are instantiated 39 | @Override 40 | public void onApplicationEvent(ApplicationContextInitializedEvent event) { 41 | Environment env = event.getApplicationContext().getEnvironment(); 42 | 43 | String dbAppUser = env.getRequiredProperty("spring.datasource.username"); 44 | String dbAppPassword = env.getRequiredProperty("spring.datasource.password"); 45 | String dbHost = env.getRequiredProperty("DB_HOST"); 46 | String dbDatabase = env.getRequiredProperty("DB_NAME"); 47 | String jdbcUrl = "jdbc:postgresql://" + dbHost + ":5432/" + dbDatabase; 48 | 49 | Properties masterConnectionProperties = new Properties(); 50 | masterConnectionProperties.put("user", env.getRequiredProperty("admin.datasource.username")); 51 | masterConnectionProperties.put("password", env.getRequiredProperty("admin.datasource.password")); 52 | 53 | // Bootstrap the database objects. The SQL is written to be idempotent so it can run each time Spring Boot 54 | // launches. This bootstrapping creates the tables and RLS policies. The RDS master user will be the owner 55 | // of these tables and by default will bypass RLS which is what we need for new tenant on-boarding where 56 | // INSERT statements would otherwise fail. 57 | // 58 | // This also will create a non root user with full read/write privileges for our application code to connect 59 | // as. This user will not be the owner of tables or other objects and will be bound by the RLS policies. 60 | try (Connection connection = DriverManager.getConnection(jdbcUrl, masterConnectionProperties); Statement sql = connection.createStatement()) { 61 | connection.setAutoCommit(false); 62 | LOGGER.info("Executing bootstrap database"); 63 | try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream("bootstrap.sql")) { 64 | Scanner scanner = new Scanner(is, "UTF-8"); 65 | // Break on blank newline so we can send the DO...END statements as a single statement 66 | scanner.useDelimiter("\n\n"); 67 | while (scanner.hasNext()) { 68 | String stmt = scanner.next() 69 | .replace("{{DB_APP_USER}}", dbAppUser) 70 | .replace("{{DB_APP_PASS}}", dbAppPassword) 71 | .trim(); 72 | sql.addBatch(stmt); 73 | } 74 | int[] count = sql.executeBatch(); 75 | connection.commit(); 76 | } catch (IOException ioe) { 77 | throw new RuntimeException(ioe); 78 | } 79 | } catch (SQLException e) { 80 | throw new RuntimeException(e); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/src/main/webapp/WEB-INF/jsp/editTenant.jsp: -------------------------------------------------------------------------------- 1 | 2 | 18 | <%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %> 19 | <%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> 20 | <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> 21 | 22 | 23 | <c:choose><c:when test="${not empty tenant.id}">Edit Tenant</c:when><c:otherwise>Add Tenant</c:otherwise></c:choose> 24 | 25 | 26 | 27 | 34 |
35 |
36 |
37 |

Edit TenantAdd Tenant

38 |
39 |
40 | 41 |
42 |
43 | 47 |
48 |
49 |
50 | 51 | 52 |
53 | 54 |
55 | Status 56 |
57 |
58 |
59 | 60 | 61 |
62 |
63 |
64 |
65 |
66 | 67 |
68 | Tier 69 |
70 |
71 |
72 | 73 | 74 |
75 |
76 |
77 |
78 |
79 | 80 |
81 | Name 82 |
83 |
84 | 85 | 86 |
87 |
88 |
89 |
90 |
91 | Cancel 92 | 93 |
94 |
95 |
96 |
97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Multi-tenant data isolation with PostgreSQL Row Level Security 2 | Checkout the article on the AWS Database Blog [https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/](https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/) 3 | 4 | Isolating tenant data is a fundamental responsibility for Software as a Service (SaaS) providers. In this sample, produced by the AWS SaaS Factory, we show you one way to implement multi-tenant data isolation using PostgreSQL row level security policies. 5 | 6 | ## Prerequisites 7 | You will need an AWS account that you have administrative access to in order to run the CloudFormation template. The demo creates a new VPC for its resources. 8 | 9 | This sample creates resources in your account that are not included in the AWS free tier. Please clean up 10 | these resources after experimenting to minimize costs. 11 | 12 | ## Getting Started 13 | 1. Simply launch the CloudFormation template [saas-factory-pg-rls.template](cfn/saas-factory-pg-rls.template) from the **cfn** folder. The stack will take a few minutes to complete. 14 | 2. If you'd like to SSH into a command line that has network access to the database and the `psql` client installed, enter a value for the EC2 keypair you'd like to use. If you don't enter a keypair, the jump box instance will not be created. 15 | 3. Enter values for the database super user name and password as well as an application user name and password. 16 | 17 | So what did CloudFormation do? The stack created a new VPC network to isolate this sample from other resources in your account. The database passwords you chose were stored as secure parameters in Systems Manager. It then created an RDS PostgreSQL instance. Using CodeBuild, the GitHub repo was cloned and our Spring Boot sample app was built into a Docker image and pushed to the ECR repository. CodeTrail then triggered CodePipeline to deploy the image to ECS which launched it with Fargate. When the Spring sample app initializes it will bootstrap the database with the tables, RLS policies, and a login user for the app to use. 18 | 19 |

Architecture Diagram

20 | 21 | A number of resources were created in your account including: 22 | - A new VPC with an Internet Gateway 23 | - 2 public subnets and 2 private subnets spread across 2 availability zones 24 | - A NAT gateway and routes in each of the public subnets 25 | - A linux EC2 instance bastion or jump box in the first public subnet with PostgreSQL command line tools installed 26 | - A security group allowing SSH access to the jump box 27 | - An RDS PosgreSQL instance in a private subnet 28 | - A security group allowing access to the RDS cluster by the jump box and by the ECS Fargate instances 29 | - An ECR repository and an ECS cluster, task definition, and service 30 | - A CloudWatch log group for ECS and 2 IAM roles for the task execution and the task itself 31 | - An application load balancer with target group and listener to front the ECS service along with a security group 32 | - A CodeBuild project and supporting S3 bucket that builds our Docker image and deploys it to the ECR repository 33 | - A CodePipline to deploy the image from the repository to the ECS service 34 | - A CodeTrail and supporting S3 bucket and CloudWatch event rule to trigger the pipeline when the build project completes 35 | - IAM roles and bucket policies for the build, pipeline and trail 36 | 37 | ## See it in action 38 | When the CloudFormation stack is completely finished, the sample environment is ready for you to experiment with. First, you need the endpoint URL for the load balancer. You can get this from the Outputs of the CloudFormation stack, or you can go to the EC2 console and find it listed under Load Balancers. 39 | 40 |

CloudFormation Outputs

41 | 42 | 1. Once you've loaded the demo application you can go to the Admin Tenant Management section and add some tenants. 43 | 2. Next, go to the Tenant User Management section and login as one of your tenants. 44 | 3. Select the same tenant under the Manage Users for list and click the Go button. 45 | 4. Add users for this tenant. 46 | 5. Logout and repeat steps 2-4 for a different tenant. 47 | 6. Now try to manage users for a tenant different than the one you've logged in as and you'll see RLS protecting your solution from cross tenant access. 48 | 49 | To see details of from the debugging logs, go to CloudWatch in your AWS Console and load the Log Group for **/ecs/saas-factory-pg-rls-app**. 50 | 51 | ## Time to clean up 52 | CloudFormation cannot delete the stack until we do a little prep work. I find it easiest to do this in the AWS Console. 53 | 1. Go to CloudFormation -> select the stack you created and delete it. It will take a few minutes to delete the stack. 54 | -------------------------------------------------------------------------------- /app/src/main/webapp/WEB-INF/jsp/editUser.jsp: -------------------------------------------------------------------------------- 1 | 2 | 18 | <%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %> 19 | <%@ taglib prefix="security" uri="http://www.springframework.org/security/tags" %> 20 | <%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> 21 | <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> 22 | 23 | 24 | <c:choose><c:when test="${not empty user.id}">Edit User</c:when><c:otherwise>Add User</c:otherwise></c:choose> 25 | 26 | 27 | 28 | 35 |
36 |
37 |
38 |

Edit UserAdd User

39 |
40 |
41 | 42 |
43 |
44 | 48 |
49 |
50 |
51 | 52 | 53 | 54 |
55 | 56 |
57 | Email 58 |
59 |
60 | 61 | 62 |
63 |
64 |
65 |
66 | 67 |
68 | First Name 69 |
70 |
71 | 72 | 73 |
74 |
75 |
76 |
77 | 78 |
79 | Last Name 80 |
81 |
82 | 83 | 84 |
85 |
86 |
87 |
88 |
89 | Cancel 90 | 91 |
92 |
93 |
94 |
95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /app/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 4.0.0 21 | 22 | com.amazon.aws.partners.saasfactory.pgrls 23 | saas-factory-pg-rls 24 | 1.0.0 25 | war 26 | 27 | 28 | MIT No Attribution License (MIT-0) 29 | https://spdx.org/licenses/MIT-0.html 30 | 31 | 32 | SaaSFactoryPgRLS 33 | AWS SaaS Factory example showing tenant data isolation using PostgreSQL Row Level Security 34 | 35 | 36 | UTF-8 37 | UTF-8 38 | 11 39 | 11 40 | 11 41 | 42 | 43 | 44 | clean package 45 | SaaSFactoryPgRLS 46 | 47 | 48 | org.springframework.boot 49 | spring-boot-maven-plugin 50 | 2.3.4.RELEASE 51 | 52 | com.amazon.aws.partners.saasfactory.pgrls.SaaSFactoryPgRLS 53 | WAR 54 | 55 | 56 | 57 | 58 | repackage 59 | 60 | 61 | 62 | 63 | 64 | org.apache.maven.plugins 65 | maven-war-plugin 66 | 3.2.3 67 | 68 | 69 | 70 | 71 | 72 | 73 | junit 74 | junit 75 | 4.13.2 76 | test 77 | 78 | 79 | org.springframework.boot 80 | spring-boot-starter-web 81 | 2.6.9 82 | 83 | 84 | org.springframework.boot 85 | spring-boot-starter-tomcat 86 | 2.6.9 87 | provided 88 | 89 | 90 | org.springframework.boot 91 | spring-boot-starter-jdbc 92 | 2.6.9 93 | 94 | 95 | org.springframework.boot 96 | spring-boot-starter-security 97 | 2.6.9 98 | 99 | 100 | org.springframework.security 101 | spring-security-taglibs 102 | 5.6.6 103 | 104 | 105 | javax.servlet 106 | jstl 107 | 1.2 108 | 109 | 110 | org.apache.tomcat.embed 111 | tomcat-embed-jasper 112 | 9.0.63 113 | provided 114 | 115 | 116 | org.webjars 117 | bootstrap 118 | 4.5.0 119 | 120 | 121 | org.webjars 122 | jquery 123 | 3.5.1 124 | 125 | 126 | software.amazon.awssdk 127 | ssm 128 | 2.17.235 129 | 130 | 131 | com.fasterxml.jackson.core 132 | jackson-core 133 | 2.13.3 134 | 135 | 136 | com.fasterxml.jackson.core 137 | jackson-annotations 138 | 2.13.3 139 | 140 | 141 | org.postgresql 142 | postgresql 143 | 42.4.3 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /app/src/main/webapp/WEB-INF/jsp/tenant.jsp: -------------------------------------------------------------------------------- 1 | 2 | 18 | <%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%> 19 | <%@ taglib prefix="security" uri="http://www.springframework.org/security/tags" %> 20 | <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> 21 | <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%> 22 | 23 | 24 | AWS SaaS Factory PostgreSQL Row Level Security 25 | 26 | 27 | 28 | 34 |
35 |
36 |
37 |

Tenant User Management

38 |

These actions will be executed in the context of the Tenant you choose to "authenticate" as and will be secured by RLS policies.

39 |

 

40 |
41 |
42 | 43 |
44 |
45 | 49 |
50 |
51 |
52 |
53 |
54 |

Currently "Authenticated" as

55 |
56 | 57 | 58 | 59 |
60 |
61 |
62 |
63 |
64 | 65 |

Manage Users for 66 |

77 | 78 | 79 |
80 |
81 | 82 |
83 |
84 |

Users

85 |
86 |
87 | Add User 88 |
89 |
90 |
91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 104 | 105 | 106 | 107 | 108 |
IDEmailName
${user.id} 103 | ${user.email}${user.givenName} ${user.familyName}
109 |
110 |
111 | 112 |
113 | 114 | 115 | 122 | 123 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/controller/AdminController.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.controller; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Status; 20 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 21 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tier; 22 | import com.amazon.aws.partners.saasfactory.pgrls.repository.UniqueRecordException; 23 | import com.amazon.aws.partners.saasfactory.pgrls.service.AdminService; 24 | import org.slf4j.Logger; 25 | import org.slf4j.LoggerFactory; 26 | import org.springframework.beans.factory.annotation.Autowired; 27 | import org.springframework.stereotype.Controller; 28 | import org.springframework.ui.Model; 29 | import org.springframework.validation.BindingResult; 30 | import org.springframework.validation.FieldError; 31 | import org.springframework.web.bind.annotation.GetMapping; 32 | import org.springframework.web.bind.annotation.ModelAttribute; 33 | import org.springframework.web.bind.annotation.PostMapping; 34 | import org.springframework.web.bind.annotation.RequestParam; 35 | import org.springframework.web.servlet.mvc.support.RedirectAttributes; 36 | 37 | import java.util.List; 38 | import java.util.UUID; 39 | 40 | @Controller 41 | public class AdminController { 42 | 43 | private final static Logger LOGGER = LoggerFactory.getLogger(RootController.class); 44 | 45 | @Autowired 46 | private AdminService adminService; 47 | 48 | @GetMapping("/admin/cancel") 49 | public String cancel() { 50 | return "redirect:/admin"; 51 | } 52 | 53 | @GetMapping("/admin") 54 | public String index(Model model) { 55 | // Load the list of tenants at the top of the view as the SaaS administrator (no RLS policies 56 | // applied because the database admin user is the table owner) 57 | List tenants = adminService.getTenants(); 58 | model.addAttribute("tenants", tenants); 59 | return "admin"; 60 | } 61 | 62 | @GetMapping("/admin/newTenant") 63 | public String newTenant(Model model) { 64 | Tenant tenant = new Tenant(); 65 | tenant.setStatus(Status.Active); 66 | model.addAttribute("tenant", tenant); 67 | model.addAttribute("statuses", Status.values()); 68 | model.addAttribute("tiers", Tier.values()); 69 | return "editTenant"; 70 | } 71 | 72 | @GetMapping("/admin/updateTenant") 73 | public String editTenant(@RequestParam("id") String id, Model model) { 74 | Tenant tenant = adminService.getTenant(UUID.fromString(id)); 75 | if (tenant == null) { 76 | model.addAttribute("css", "danger"); 77 | model.addAttribute("msg", "No tenant for id " + id); 78 | } 79 | model.addAttribute("tenant", tenant); 80 | model.addAttribute("statuses", Status.values()); 81 | model.addAttribute("tiers", Tier.values()); 82 | return "editTenant"; 83 | } 84 | 85 | @PostMapping("/admin/editTenant") 86 | public String saveTenant(@ModelAttribute Tenant tenant, BindingResult binding, Model model, final RedirectAttributes redirectAttributes) { 87 | String view = null; 88 | if (tenant.getName() == null || tenant.getName().isEmpty()) { 89 | binding.addError(new FieldError("tenant", "name", "Tenant name is required")); 90 | view = "editTenant"; 91 | } else { 92 | try { 93 | boolean isNew = (tenant.getId() == null); 94 | adminService.saveTenant(tenant); 95 | redirectAttributes.addFlashAttribute("css", "success"); 96 | if (isNew) { 97 | redirectAttributes.addFlashAttribute("msg", "New tenant added"); 98 | } else { 99 | redirectAttributes.addFlashAttribute("msg", "Tenant updated"); 100 | } 101 | view = "redirect:/admin"; 102 | } catch (UniqueRecordException e) { 103 | binding.addError(new FieldError("tenant", "name", "Tenant already exists")); 104 | view = "editTenant"; 105 | } 106 | } 107 | model.addAttribute("statuses", Status.values()); 108 | model.addAttribute("tiers", Tier.values()); 109 | return view; 110 | } 111 | 112 | @GetMapping("/admin/deleteTenant") 113 | public String deleteTenantConfirm(@RequestParam("id") String id, Model model) { 114 | Tenant tenant = adminService.getTenant(UUID.fromString(id)); 115 | if (tenant == null) { 116 | model.addAttribute("css", "danger"); 117 | model.addAttribute("msg", "No tenant for id " + id); 118 | } 119 | model.addAttribute("tenant", tenant); 120 | return "deleteTenant"; 121 | } 122 | 123 | @PostMapping("/admin/deleteTenant") 124 | public String deleteTenant(@ModelAttribute Tenant tenant, BindingResult binding, Model model, final RedirectAttributes redirectAttributes) { 125 | String view = null; 126 | try { 127 | adminService.deleteTenant(tenant); 128 | redirectAttributes.addFlashAttribute("css", "success"); 129 | redirectAttributes.addFlashAttribute("msg", "Tenant deleted"); 130 | view = "redirect:/admin"; 131 | } catch (Exception e) { 132 | model.addAttribute("css", "danger"); 133 | model.addAttribute("msg", "Failed to delete tenant: " + e.getMessage()); 134 | view = "deleteTenant"; 135 | } 136 | return view; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/service/AdminServiceImpl.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.service; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 20 | import com.amazon.aws.partners.saasfactory.pgrls.repository.AdminDataSourceRepository; 21 | import com.amazon.aws.partners.saasfactory.pgrls.repository.UniqueRecordException; 22 | import org.slf4j.Logger; 23 | import org.slf4j.LoggerFactory; 24 | import org.springframework.beans.factory.annotation.Autowired; 25 | import org.springframework.dao.DataAccessException; 26 | import org.springframework.dao.EmptyResultDataAccessException; 27 | import org.springframework.jdbc.core.JdbcTemplate; 28 | import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; 29 | import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; 30 | import org.springframework.jdbc.support.GeneratedKeyHolder; 31 | import org.springframework.stereotype.Service; 32 | 33 | import java.sql.SQLException; 34 | import java.sql.Types; 35 | import java.util.ArrayList; 36 | import java.util.List; 37 | import java.util.UUID; 38 | 39 | /** 40 | * In a more complete solution, you'd break up your business logic 41 | * and error handling here and move the data access code to another 42 | * set of interfaces. 43 | * @author mibeard 44 | */ 45 | @Service 46 | public class AdminServiceImpl implements AdminService { 47 | 48 | private static final Logger LOGGER = LoggerFactory.getLogger(AdminServiceImpl.class); 49 | 50 | private JdbcTemplate admin; 51 | 52 | @Autowired 53 | public AdminServiceImpl(AdminDataSourceRepository adminRepo) { 54 | admin = new JdbcTemplate(adminRepo.dataSource()); 55 | } 56 | 57 | private JdbcTemplate admin() { 58 | return admin; 59 | } 60 | 61 | public Tenant saveTenant(Tenant tenant) { 62 | Tenant saved = null; 63 | if (tenant.getId() == null) { 64 | saved = insertTenant(tenant); 65 | } else { 66 | saved = updateTenant(tenant); 67 | } 68 | return saved; 69 | } 70 | 71 | /** 72 | * If we want the database to be in charge of its key rather than the client, then we create a chicken and 73 | * egg problem with RLS because the tenant_id value will be null in the INSERT statement and won't match the 74 | * current tenant context. We will use the admin connection to run this SQL and it won't have RLS applied. 75 | * @param tenant 76 | * @return Newly registered tenant 77 | */ 78 | protected Tenant insertTenant(Tenant tenant) { 79 | // Have to use named parameters in order to capture 80 | // the database-generated id 81 | NamedParameterJdbcTemplate jdbc = new NamedParameterJdbcTemplate(admin()); 82 | GeneratedKeyHolder generated = new GeneratedKeyHolder(); 83 | 84 | // Building our own SQL because of the enums... yuck 85 | StringBuilder sql = new StringBuilder("INSERT INTO tenant (name"); 86 | StringBuilder values = new StringBuilder(" VALUES (:name"); 87 | MapSqlParameterSource params = new MapSqlParameterSource(); 88 | params.addValue("name", tenant.getName()); 89 | if (tenant.getStatus() != null) { 90 | sql.append(", status"); 91 | values.append(", :status"); 92 | params.addValue("status", tenant.getStatusAsString(), Types.VARCHAR); 93 | } 94 | if (tenant.getTier() != null) { 95 | sql.append(", tier"); 96 | values.append(", :tier"); 97 | params.addValue("tier", tenant.getTierAsString(), Types.VARCHAR); 98 | } 99 | sql.append(")"); 100 | values.append(")"); 101 | sql.append(values); 102 | 103 | try { 104 | int update = jdbc.update(sql.toString(), params, generated); 105 | if (update == 1) { 106 | UUID tenantId = (UUID) generated.getKeys().get("tenant_id"); 107 | tenant.setId(tenantId); 108 | } else { 109 | // todo throw error here? 110 | } 111 | } catch (DataAccessException e) { 112 | if (e.getRootCause() instanceof SQLException) { 113 | SQLException sqlError = (SQLException) e.getRootCause(); 114 | if ("23505".equals(sqlError.getSQLState())) { 115 | throw new UniqueRecordException(tenant.getName() + " already exists", e); 116 | } else { 117 | throw e; 118 | } 119 | } else { 120 | throw e; 121 | } 122 | } 123 | return tenant; 124 | } 125 | 126 | protected Tenant updateTenant(Tenant tenant) { 127 | Tenant updated = null; 128 | int rowsEffected = admin().update("UPDATE tenant SET name = ?, status = ?, tier = ? WHERE tenant_id = ?", tenant.getName(), tenant.getStatusAsString(), tenant.getTierAsString(), tenant.getId()); 129 | if (rowsEffected == 1) { 130 | updated = getTenant(tenant.getId()); 131 | } 132 | return updated; 133 | } 134 | 135 | /** 136 | * Listing all tenants is an admin function. This SQL will run 137 | * properly under RLS and you'll only get 1 row in the result 138 | * set -- the one that matches the current tenant context. 139 | * @return 140 | */ 141 | @Override 142 | public List getTenants() { 143 | List tenants = new ArrayList<>(); 144 | try { 145 | tenants = admin().query("SELECT tenant_id, name, status, tier FROM tenant", new TenantRowMapper()); 146 | } catch (EmptyResultDataAccessException e) { 147 | // If row level security policies aren't met, it's not 148 | // an exception from the database, it's just as if the 149 | // data didn't exist in the table. 150 | } 151 | return tenants; 152 | } 153 | 154 | public Tenant getTenant(UUID tenantId) { 155 | Tenant tenant = null; 156 | try { 157 | tenant = admin().queryForObject("SELECT tenant_id, name, status, tier FROM tenant WHERE tenant_id = ?", new TenantRowMapper(), tenantId); 158 | } catch (EmptyResultDataAccessException e) { 159 | } 160 | return tenant; 161 | } 162 | 163 | @Override 164 | public void deleteTenant(Tenant tenant) { 165 | admin().update("DELETE FROM tenant WHERE tenant_id = ?", tenant.getId()); 166 | } 167 | 168 | public void deleteTenantUsers(Tenant tenant) { 169 | admin().update("DELETE FROM tenant_user WHERE tenant_id = ?", tenant.getId()); 170 | } 171 | 172 | @Override 173 | public boolean tenantExists(UUID tenantId) { 174 | boolean exists = false; 175 | try { 176 | exists = admin().queryForObject("SELECT EXISTS(SELECT * FROM tenant WHERE tenant_id = ?)", Boolean.class, tenantId); 177 | } catch (Exception e) { 178 | LOGGER.error("Error selecting tenant exists {}", tenantId, e); 179 | } 180 | return exists; 181 | } 182 | 183 | @Override 184 | public boolean userExists(UUID userId) { 185 | boolean exists = false; 186 | try { 187 | exists = admin().queryForObject("SELECT EXISTS(SELECT * FROM tenant_user WHERE user_id = ?)", Boolean.class, userId); 188 | } catch (Exception e) { 189 | LOGGER.error("Error selecting tenant exists {}", userId, e); 190 | } 191 | return exists; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/service/TenantServiceImpl.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.service; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 20 | import com.amazon.aws.partners.saasfactory.pgrls.UnauthorizedException; 21 | import com.amazon.aws.partners.saasfactory.pgrls.domain.User; 22 | import com.amazon.aws.partners.saasfactory.pgrls.repository.DataSourceRepository; 23 | import com.amazon.aws.partners.saasfactory.pgrls.repository.UniqueRecordException; 24 | import org.slf4j.Logger; 25 | import org.slf4j.LoggerFactory; 26 | import org.springframework.beans.factory.annotation.Autowired; 27 | import org.springframework.dao.DataAccessException; 28 | import org.springframework.dao.EmptyResultDataAccessException; 29 | import org.springframework.jdbc.BadSqlGrammarException; 30 | import org.springframework.jdbc.core.JdbcTemplate; 31 | import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; 32 | import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; 33 | import org.springframework.jdbc.support.GeneratedKeyHolder; 34 | import org.springframework.security.authentication.AnonymousAuthenticationToken; 35 | import org.springframework.security.core.Authentication; 36 | import org.springframework.security.core.context.SecurityContextHolder; 37 | import org.springframework.stereotype.Service; 38 | 39 | import java.sql.Connection; 40 | import java.sql.ResultSet; 41 | import java.sql.SQLException; 42 | import java.sql.Statement; 43 | import java.util.ArrayList; 44 | import java.util.List; 45 | import java.util.UUID; 46 | 47 | /** 48 | * In a more complete solution, you'd break up your business logic and error handling here and move 49 | * the data access code to another set of interfaces. 50 | * @author mibeard 51 | */ 52 | @Service 53 | public class TenantServiceImpl implements TenantService { 54 | 55 | private static final Logger LOGGER = LoggerFactory.getLogger(TenantServiceImpl.class); 56 | 57 | @Autowired 58 | private DataSourceRepository repo; 59 | 60 | // We have to "lazy load" the JDBC Template at runtime because there won't be an authenticated tenant 61 | // to map the connection pool to. Because we have a connection pool per-tenant, we have to ask the 62 | // repository for the data source each time to ensure that we get a connection back from the pool that 63 | // reflects the current tenant context. If you auto wired the JDBC Template, Spring would want to 64 | // inject a singleton data source. 65 | // 66 | // We could choose to reuse a JDBC Template instance. We'd still need to set the data source each time. 67 | // The savings would be in the JdbcTemplate not loading the exception translator (which it does when 68 | // it's instantiated). This would also expose a class member that could be mistakenly used below. 69 | private JdbcTemplate jdbc() { 70 | JdbcTemplate jdbc = new JdbcTemplate(repo.dataSource()); 71 | 72 | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); 73 | if (authentication != null && !(authentication instanceof AnonymousAuthenticationToken)) { 74 | LOGGER.info("Spring current tenant = '{}'", ((Tenant) authentication.getPrincipal()).getId()); 75 | } 76 | try (Connection conn = jdbc.getDataSource().getConnection(); Statement stmt = conn.createStatement()) { 77 | ResultSet rs = stmt.executeQuery("SHOW app.current_tenant"); 78 | rs.next(); 79 | String connectionCurrentTenant = rs.getString(1); 80 | rs.close(); 81 | LOGGER.info("PostgreSQL current tenant = '{}' on {}", connectionCurrentTenant, jdbc.getDataSource().toString()); 82 | } catch (SQLException e) { 83 | LOGGER.error("Error fetching PostgreSQL session variable app.current_tenant", e); 84 | } 85 | 86 | return jdbc; 87 | } 88 | 89 | @Override 90 | public Tenant getTenant(UUID tenantId) { 91 | Tenant tenant = null; 92 | try { 93 | tenant = jdbc().queryForObject("SELECT tenant_id, name, status, tier FROM tenant WHERE tenant_id = ?", new TenantRowMapper(), tenantId); 94 | tenant.setUsers(getUsers(tenant)); 95 | } catch (EmptyResultDataAccessException e) { 96 | // If row level security policies aren't met, it's not 97 | // an exception from the database, it's just as if the 98 | // data didn't exist in the table. 99 | } 100 | return tenant; 101 | } 102 | 103 | @Override 104 | public Tenant saveTenant(Tenant tenant) { 105 | Tenant saved = null; 106 | int updated = jdbc().update("UPDATE tenant SET name = ?, status = ?, tier = ? WHERE tenant_id = ?", tenant.getName(), tenant.getStatus(), tenant.getTier(), tenant.getId()); 107 | if (updated == 1) { 108 | saved = getTenant(tenant.getId()); 109 | } 110 | return saved; 111 | } 112 | 113 | @Override 114 | public List getUsers(Tenant tenant) { 115 | List users = new ArrayList<>(); 116 | try { 117 | users = jdbc().query("SELECT tenant_id, user_id, email, given_name, family_name FROM tenant_user WHERE tenant_id = ?", new UserRowMapper(), tenant.getId()); 118 | } catch (EmptyResultDataAccessException e) { 119 | // If row level security policies aren't met, it's not 120 | // an exception from the database, it's just as if the 121 | // data didn't exist in the table. 122 | } 123 | return users; 124 | } 125 | 126 | /** 127 | * Notice that there is nothing special about these queries. You don't have to add tenant_id = ? to your SQL. 128 | * RLS protection is transparent to us because it's managed in the connection. 129 | * @param userId 130 | * @return the user with id userId 131 | */ 132 | @Override 133 | public User getUser(UUID userId) { 134 | User user = null; 135 | try { 136 | user = jdbc().queryForObject("SELECT tenant_id, user_id, email, given_name, family_name FROM tenant_user WHERE user_id = ?", new UserRowMapper(), userId); 137 | } catch (EmptyResultDataAccessException e) { 138 | // If row level security policies aren't met, it's not 139 | // an exception from the database, it's just as if the 140 | // data didn't exist in the table. 141 | } 142 | return user; 143 | } 144 | 145 | @Override 146 | public User saveUser(User user) { 147 | User saved = null; 148 | if (user.getId() == null) { 149 | saved = insertUser(user); 150 | } else { 151 | saved = updateUser(user); 152 | } 153 | return saved; 154 | } 155 | 156 | protected User insertUser(User user) { 157 | NamedParameterJdbcTemplate jdbc = new NamedParameterJdbcTemplate(jdbc()); 158 | GeneratedKeyHolder generated = new GeneratedKeyHolder(); 159 | StringBuilder sql = new StringBuilder("INSERT INTO tenant_user (tenant_id, email, given_name, family_name) VALUES (:tenant_id, :email, :given_name, :family_name)"); 160 | MapSqlParameterSource params = new MapSqlParameterSource() 161 | .addValue("tenant_id", user.getTenant().getId()) 162 | .addValue("email", user.getEmail()) 163 | .addValue("given_name", user.getGivenName()) 164 | .addValue("family_name", user.getFamilyName()); 165 | try { 166 | int update = jdbc.update(sql.toString(), params, generated); 167 | if (update == 1) { 168 | UUID userId = (UUID) generated.getKeys().get("user_id"); 169 | user.setId(userId); 170 | user.setTenant(getTenant(user.getTenant().getId())); 171 | } 172 | } catch (BadSqlGrammarException e) { 173 | // Postgres will throw an Access Rule Violation error with condition 174 | // insufficient_privilege if an INSERT fails to satisfy an RLS policy. 175 | // ERROR: 42501: new row violates row-level security policy for table... 176 | if ("42501".equals(e.getSQLException().getSQLState())) { 177 | throw new UnauthorizedException(); 178 | } else { 179 | throw e; 180 | } 181 | } catch (DataAccessException e) { 182 | if (e.getRootCause() instanceof SQLException) { 183 | SQLException sqlError = (SQLException) e.getRootCause(); 184 | if ("23505".equals(sqlError.getSQLState())) { 185 | throw new UniqueRecordException(user.getEmail() + " already exists", e); 186 | } else { 187 | throw e; 188 | } 189 | } else { 190 | throw e; 191 | } 192 | } 193 | return user; 194 | } 195 | 196 | /** 197 | * Notice that there is nothing special about these queries. You don't have to add tenant_id = ? to your SQL. 198 | * RLS protection is transparent to us because it's managed in the connection. 199 | * @param user 200 | * @return the updated user 201 | */ 202 | protected User updateUser(User user) { 203 | User updated = null; 204 | int rowsEffected = jdbc().update("UPDATE tenant_user SET email = ?, given_name = ?, family_name = ? WHERE user_id = ?", user.getEmail(), user.getGivenName(), user.getFamilyName(), user.getId()); 205 | if (rowsEffected == 1) { 206 | updated = getUser(user.getId()); 207 | } 208 | return updated; 209 | } 210 | 211 | @Override 212 | public void deleteUser(User user) { 213 | int rowsEffected = jdbc().update("DELETE FROM tenant_user WHERE user_id = ?", user.getId()); 214 | LOGGER.info("Delete from tenant_user returned {} effected rows", rowsEffected); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /app/src/main/java/com/amazon/aws/partners/saasfactory/pgrls/controller/TenantController.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | * software and associated documentation files (the "Software"), to deal in the Software 6 | * without restriction, including without limitation the rights to use, copy, modify, 7 | * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | * permit persons to whom the Software is furnished to do so. 9 | * 10 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | */ 17 | package com.amazon.aws.partners.saasfactory.pgrls.controller; 18 | 19 | import com.amazon.aws.partners.saasfactory.pgrls.UnauthorizedException; 20 | import com.amazon.aws.partners.saasfactory.pgrls.domain.Tenant; 21 | import com.amazon.aws.partners.saasfactory.pgrls.domain.User; 22 | import com.amazon.aws.partners.saasfactory.pgrls.repository.UniqueRecordException; 23 | import com.amazon.aws.partners.saasfactory.pgrls.service.AdminService; 24 | import com.amazon.aws.partners.saasfactory.pgrls.service.TenantService; 25 | import org.slf4j.Logger; 26 | import org.slf4j.LoggerFactory; 27 | import org.springframework.beans.factory.annotation.Autowired; 28 | import org.springframework.security.core.Authentication; 29 | import org.springframework.security.core.context.SecurityContextHolder; 30 | import org.springframework.stereotype.Controller; 31 | import org.springframework.ui.Model; 32 | import org.springframework.validation.BindingResult; 33 | import org.springframework.validation.FieldError; 34 | import org.springframework.web.bind.WebDataBinder; 35 | import org.springframework.web.bind.annotation.*; 36 | import org.springframework.web.context.request.WebRequest; 37 | import org.springframework.web.servlet.mvc.support.RedirectAttributes; 38 | 39 | import java.beans.PropertyEditorSupport; 40 | import java.util.UUID; 41 | 42 | @Controller 43 | public class TenantController { 44 | 45 | private final static Logger LOGGER = LoggerFactory.getLogger(TenantController.class); 46 | 47 | @Autowired 48 | private TenantService tenantService; 49 | 50 | @Autowired 51 | private AdminService adminService; 52 | 53 | @InitBinder 54 | public void initBinder(WebDataBinder binder) { 55 | binder.registerCustomEditor(Tenant.class, new TenantEditor()); 56 | } 57 | 58 | @GetMapping("/tenant/cancel") 59 | public String cancel() { 60 | return "redirect:/tenant"; 61 | } 62 | 63 | @GetMapping("/tenant") 64 | public String index(Authentication authentication, Model model) { 65 | LOGGER.info("Authenticated tenant {}", ((Tenant) authentication.getPrincipal()).getId()); 66 | Tenant tenant = new Tenant(); 67 | if (model.containsAttribute("selectedTenant")) { 68 | String selectedTenantId = String.valueOf(model.getAttribute("selectedTenant")); 69 | LOGGER.info("Redirected with existing selected tenant {}", selectedTenantId); 70 | tenant.setId(selectedTenantId); 71 | } 72 | // Provide a list of all tenants so the demo can force cross-tenant access 73 | model.addAttribute("tenants", adminService.getTenants()); 74 | model.addAttribute("selectedTenant", tenant); 75 | return "tenant"; 76 | } 77 | 78 | @PostMapping("/tenant") 79 | public String listUsers(Authentication authentication, @RequestParam String tenantId, Model model) { 80 | Tenant authenticatedTenant = (Tenant) authentication.getPrincipal(); 81 | Tenant tenant = new Tenant(); 82 | try { 83 | tenant.setId(UUID.fromString(tenantId)); 84 | try { 85 | // Load the list of tenant users as the currently logged in tenant. 86 | // But, ask for the users for a specific tenant id. If the 2 ids don't match, 87 | // RLS will prevent cross tenant access to the other tenant's resources without 88 | // having to specify ...WHERE tenant_id = ? in the SQL queries. 89 | Tenant tenantForEdit = tenantService.getTenant(tenant.getId()); 90 | if (tenantForEdit == null) { 91 | LOGGER.info("Database security policies prevented cross tenant access"); 92 | model.addAttribute("css", "danger"); 93 | model.addAttribute("msg", "Row Level Security policies prevented " + authenticatedTenant.getId().toString() + " from accessing data for " + tenantId); 94 | } else { 95 | tenant = tenantForEdit; 96 | } 97 | } catch (Exception e) { 98 | model.addAttribute("css", "danger"); 99 | model.addAttribute("msg", e.getMessage()); 100 | } 101 | } catch (IllegalArgumentException e) { 102 | model.addAttribute("css", "danger"); 103 | model.addAttribute("msg", "Invalid tenant id"); 104 | } 105 | 106 | model.addAttribute("tenants", adminService.getTenants()); 107 | model.addAttribute("selectedTenant", tenant); 108 | return "tenant"; 109 | } 110 | 111 | @GetMapping("/tenant/newUser") 112 | public String newUser(@RequestParam String tenantId, Model model) { 113 | User user = new User(); 114 | user.setTenant(new Tenant(UUID.fromString(tenantId))); 115 | model.addAttribute("user", user); 116 | return "editUser"; 117 | } 118 | 119 | @GetMapping("/tenant/updateUser") 120 | public String editUser(Authentication authentication, @RequestParam("id") String id, Model model) { 121 | UUID userId = UUID.fromString(id); 122 | User user = tenantService.getUser(userId); 123 | if (user == null) { 124 | // For this demo, just to show RLS in action, see if the user exists 125 | if (adminService.userExists(userId)) { 126 | Tenant authenticatedTenant = (Tenant) authentication.getPrincipal(); 127 | model.addAttribute("css", "danger"); 128 | model.addAttribute("msg", "Row Level Security policies prevented " + authenticatedTenant.getId().toString() + " from accessing user " + userId); 129 | } else { 130 | model.addAttribute("css", "danger"); 131 | model.addAttribute("msg", "No tenant for id " + id); 132 | } 133 | } 134 | model.addAttribute("user", user); 135 | return "editUser"; 136 | } 137 | 138 | @PostMapping("/tenant/editUser") 139 | public String saveTenant(Authentication authentication, @ModelAttribute User user, BindingResult binding, Model model, final RedirectAttributes redirectAttributes, WebRequest request) { 140 | String view = null; 141 | if (user.getEmail() == null || user.getEmail().isEmpty()) { 142 | binding.addError(new FieldError("user", "email", "User email is required")); 143 | view = "editUser"; 144 | } else if (user.getGivenName() == null || user.getGivenName().isEmpty()) { 145 | binding.addError(new FieldError("user", "giveName", "User first name is required")); 146 | view = "editUser"; 147 | } else if (user.getFamilyName() == null || user.getFamilyName().isEmpty()) { 148 | binding.addError(new FieldError("user", "familyName", "User last name is required")); 149 | view = "editUser"; 150 | } else if (user.getTenant() == null || user.getTenant().getId() == null) { 151 | String requestedTenantId = request.getParameter("tenant"); 152 | if (requestedTenantId != null && !requestedTenantId.isEmpty() && adminService.tenantExists(UUID.fromString(requestedTenantId))) { 153 | Tenant authenticatedTenant = (Tenant) authentication.getPrincipal(); 154 | LOGGER.warn("Row Level Security policies prevented " + authenticatedTenant.getIdAsString() + " from accessing data for tenant " + requestedTenantId); 155 | redirectAttributes.addFlashAttribute("css", "danger"); 156 | redirectAttributes.addFlashAttribute("msg", "Row Level Security policies prevented " + authenticatedTenant.getIdAsString() + " from accessing data for tenant " + requestedTenantId); 157 | view = "redirect:/tenant"; 158 | } else { 159 | LOGGER.error("Unable to load tenant for user from input " + requestedTenantId); 160 | redirectAttributes.addFlashAttribute("css", "danger"); 161 | redirectAttributes.addFlashAttribute("msg", "Unable to load tenant for user from input"); 162 | view = "redirect:/tenant"; 163 | } 164 | } else { 165 | try { 166 | boolean isNew = (user.getId() == null); 167 | LOGGER.info("Saving {}user {}", isNew ? "new " : "", user.getEmail()); 168 | user = tenantService.saveUser(user); 169 | redirectAttributes.addFlashAttribute("css", "success"); 170 | if (isNew) { 171 | redirectAttributes.addFlashAttribute("msg", "New user added"); 172 | } else { 173 | redirectAttributes.addFlashAttribute("msg", "User updated"); 174 | } 175 | // Add the tenant back into model for the redirect 176 | redirectAttributes.addFlashAttribute("selectedTenant", user.getTenant().getId()); 177 | view = "redirect:/tenant"; 178 | } catch (UnauthorizedException e) { 179 | LOGGER.warn("Authenticated tenant is not authorized to save user for current tenant"); 180 | Tenant authenticatedTenant = (Tenant) authentication.getPrincipal(); 181 | redirectAttributes.addFlashAttribute("css", "danger"); 182 | redirectAttributes.addFlashAttribute("msg", "Row Level Security policies prevented " + authenticatedTenant.getIdAsString() + " from creating a user"); 183 | view = "editUser"; 184 | } catch (UniqueRecordException e) { 185 | LOGGER.warn("Duplicate user email error"); 186 | binding.addError(new FieldError("user", "email", "User already exists")); 187 | view = "editUser"; 188 | } 189 | } 190 | return view; 191 | } 192 | 193 | @GetMapping("/tenant/deleteUser") 194 | public String deleteUserConfirm(Authentication authentication, @RequestParam("id") String id, Model model, final RedirectAttributes redirectAttributes) { 195 | String view = null; 196 | UUID userId = UUID.fromString(id); 197 | User user = tenantService.getUser(userId); 198 | if (user == null) { 199 | user = new User(); 200 | if (adminService.userExists(userId)) { 201 | LOGGER.warn("Authenticated tenant is not authorized to save user for current tenant"); 202 | Tenant authenticatedTenant = (Tenant) authentication.getPrincipal(); 203 | redirectAttributes.addFlashAttribute("css", "danger"); 204 | redirectAttributes.addFlashAttribute("msg", "Row Level Security policies prevented " + authenticatedTenant.getIdAsString() + " from deleting user " + id); 205 | view = "redirect:/tenant"; 206 | } else { 207 | model.addAttribute("css", "danger"); 208 | model.addAttribute("msg", "No user for id " + id); 209 | view = "deleteUser"; 210 | } 211 | } else { 212 | view = "deleteUser"; 213 | } 214 | model.addAttribute("user", user); 215 | return view; 216 | } 217 | 218 | @PostMapping("/tenant/deleteUser") 219 | public String deleteUser(Authentication authentication, @ModelAttribute User user, BindingResult binding, Model model, final RedirectAttributes redirectAttributes) { 220 | LOGGER.info("Deleting user " + user.getId()); 221 | String view = null; 222 | try { 223 | Tenant authenticatedTenant = (Tenant) authentication.getPrincipal(); 224 | tenantService.deleteUser(user); 225 | // For this demo, just to show RLS in action, see if the user exists 226 | if (adminService.userExists(user.getId())) { 227 | LOGGER.warn("Row Level Security policies prevented " + authenticatedTenant.getIdAsString() + " from deleting user " + user.getId().toString()); 228 | redirectAttributes.addFlashAttribute("css", "danger"); 229 | redirectAttributes.addFlashAttribute("msg", "Row Level Security policies prevented " + authenticatedTenant.getIdAsString() + " from deleting user " + user.getId().toString()); 230 | view = "redirect:/tenant"; 231 | } else { 232 | LOGGER.info("User delete succeeded"); 233 | redirectAttributes.addFlashAttribute("css", "success"); 234 | redirectAttributes.addFlashAttribute("msg", "User deleted"); 235 | // Add the tenant back into model for the redirect 236 | redirectAttributes.addFlashAttribute("selectedTenant", authenticatedTenant.getId()); 237 | view = "redirect:/tenant"; 238 | } 239 | } catch (Exception e) { 240 | LOGGER.error("Error deleting user", e); 241 | model.addAttribute("css", "danger"); 242 | model.addAttribute("msg", "Failed to delete user: " + e.getMessage()); 243 | view = "deleteUser"; 244 | } 245 | return view; 246 | } 247 | 248 | private final class TenantEditor extends PropertyEditorSupport { 249 | @Override 250 | public String getAsText() { 251 | Tenant tenant = (Tenant) getValue(); 252 | return tenant == null ? "" : tenant.getId().toString(); 253 | } 254 | 255 | @Override 256 | public void setAsText(String text) throws IllegalArgumentException { 257 | Tenant tenant = null; 258 | try { 259 | tenant = tenantService.getTenant(UUID.fromString(text)); 260 | } catch (Exception e) { 261 | LOGGER.error("Can't look up tenant by id {}", text, e); 262 | } 263 | setValue(tenant); 264 | } 265 | } 266 | 267 | } 268 | -------------------------------------------------------------------------------- /cfn/saas-factory-pg-rls.template: -------------------------------------------------------------------------------- 1 | --- 2 | # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 5 | # software and associated documentation files (the "Software"), to deal in the Software 6 | # without restriction, including without limitation the rights to use, copy, modify, 7 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so. 9 | # 10 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 11 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 12 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 13 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 14 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 15 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | AWSTemplateFormatVersion: '2010-09-09' 17 | Description: AWS SaaS Factory Multi-Tenant RDBMS Data Isolation Using PostgreSQL Row Level Security 18 | Parameters: 19 | KeyPair: 20 | Description: Amazon EC2 Key Pair for a Jump Box with access to the database 21 | #Type: AWS::EC2::KeyPair::KeyName 22 | Type: String 23 | DBName: 24 | Description: RDS Database Name 25 | Type: String 26 | MinLength: 3 27 | MaxLength: 31 28 | AllowedPattern: ^[a-zA-Z]+[a-zA-Z0-9_\$]*$ 29 | ConstraintDescription: Database name must be between 3 and 31 characters in length 30 | DBMasterUsername: 31 | Description: RDS Master Username 32 | Type: String 33 | DBMasterPassword: 34 | Description: RDS Master User Password 35 | Type: String 36 | NoEcho: true 37 | MinLength: 8 38 | AllowedPattern: ^[a-zA-Z0-9/@"' ]{8,}$ 39 | ConstraintDescription: RDS passwords must be at least 8 characters in length 40 | DBAppUsername: 41 | Description: RDS Application Username 42 | Type: String 43 | DBAppPassword: 44 | Description: RDS Application User Password 45 | Type: String 46 | NoEcho: true 47 | MinLength: 8 48 | AllowedPattern: ^[a-zA-Z0-9/@"' ]{8,}$ 49 | ConstraintDescription: RDS passwords must be at least 8 characters in length 50 | RDSInstanceType: 51 | Description: RDS Instance Type 52 | Type: String 53 | Default: db.t2.micro 54 | RDSEngineVersion: 55 | Description: PostgreSQL version 56 | Type: String 57 | Default: 12 58 | AMI: 59 | Description: EC2 Image ID for the Jump Box (don't change) 60 | Type: AWS::SSM::Parameter::Value 61 | Default: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2 62 | Metadata: 63 | AWS::CloudFormation::Interface: 64 | ParameterGroups: 65 | - Label: 66 | default: EC2 Configuration 67 | Parameters: 68 | - KeyPair 69 | - AMI 70 | - Label: 71 | default: Database Configuration 72 | Parameters: 73 | - RDSInstanceType 74 | - RDSEngineVersion 75 | - DBName 76 | - DBMasterUsername 77 | - DBMasterPassword 78 | - DBAppUsername 79 | - DBAppPassword 80 | ParameterLabels: 81 | KeyPair: 82 | default: Key Pair for the Jump box 83 | DBName: 84 | default: Database Name 85 | DBMasterUsername: 86 | default: RDS Master Username 87 | DBMasterPassword: 88 | default: RDS Master Password 89 | DBAppUsername: 90 | default: RDS Application Username 91 | DBAppPassword: 92 | default: RDS Application Password 93 | AMI: 94 | default: Do Not Change - Jump Box AMI 95 | Conditions: 96 | HasKeyPair: !Not [!Equals [!Ref KeyPair, '']] 97 | Resources: 98 | VPC: 99 | Type: AWS::EC2::VPC 100 | Properties: 101 | CidrBlock: 10.0.0.0/16 102 | EnableDnsSupport: true 103 | EnableDnsHostnames: true 104 | Tags: 105 | - Key: Name 106 | Value: saas-factory-pg-rls-vpc 107 | InternetGateway: 108 | Type: AWS::EC2::InternetGateway 109 | Properties: 110 | Tags: 111 | - Key: Name 112 | Value: saas-factory-pg-rls-igw 113 | AttachGateway: 114 | Type: AWS::EC2::VPCGatewayAttachment 115 | Properties: 116 | VpcId: !Ref VPC 117 | InternetGatewayId: !Ref InternetGateway 118 | RouteTablePublic: 119 | Type: AWS::EC2::RouteTable 120 | Properties: 121 | VpcId: !Ref VPC 122 | Tags: 123 | - Key: Name 124 | Value: saas-factory-pg-rls-route-pub 125 | RoutePublic: 126 | Type: AWS::EC2::Route 127 | DependsOn: AttachGateway 128 | Properties: 129 | RouteTableId: !Ref RouteTablePublic 130 | DestinationCidrBlock: 0.0.0.0/0 131 | GatewayId: !Ref InternetGateway 132 | SubnetPublicA: 133 | Type: AWS::EC2::Subnet 134 | Properties: 135 | VpcId: !Ref VPC 136 | AvailabilityZone: !Select [0, !GetAZs ''] 137 | CidrBlock: 10.0.32.0/19 138 | Tags: 139 | - Key: Name 140 | Value: saas-factory-pg-rls-subA-pub 141 | SubnetPublicARouteTable: 142 | Type: AWS::EC2::SubnetRouteTableAssociation 143 | Properties: 144 | SubnetId: !Ref SubnetPublicA 145 | RouteTableId: !Ref RouteTablePublic 146 | SubnetPublicB: 147 | Type: AWS::EC2::Subnet 148 | Properties: 149 | VpcId: !Ref VPC 150 | AvailabilityZone: !Select [1, !GetAZs ''] 151 | CidrBlock: 10.0.96.0/19 152 | Tags: 153 | - Key: Name 154 | Value: saas-factory-pg-rls-subB-pub 155 | SubnetPublicBRouteTable: 156 | Type: AWS::EC2::SubnetRouteTableAssociation 157 | Properties: 158 | SubnetId: !Ref SubnetPublicB 159 | RouteTableId: !Ref RouteTablePublic 160 | NatGatewayAddrA: 161 | Type: AWS::EC2::EIP 162 | DependsOn: AttachGateway 163 | Properties: 164 | Domain: vpc 165 | NatGatewayA: 166 | Type: AWS::EC2::NatGateway 167 | Properties: 168 | AllocationId: !GetAtt NatGatewayAddrA.AllocationId 169 | SubnetId: !Ref SubnetPublicA 170 | Tags: 171 | - Key: Name 172 | Value: saas-factory-pg-rls-nat-subA-pub 173 | RouteTableNatA: 174 | Type: AWS::EC2::RouteTable 175 | Properties: 176 | VpcId: !Ref VPC 177 | Tags: 178 | - Key: Name 179 | Value: saas-factory-pg-rls-route-natA 180 | RouteNatA: 181 | Type: AWS::EC2::Route 182 | DependsOn: NatGatewayA 183 | Properties: 184 | RouteTableId: !Ref RouteTableNatA 185 | DestinationCidrBlock: 0.0.0.0/0 186 | NatGatewayId: !Ref NatGatewayA 187 | SubnetPrivateA: 188 | Type: AWS::EC2::Subnet 189 | Properties: 190 | VpcId: !Ref VPC 191 | AvailabilityZone: !Select [0, !GetAZs ''] 192 | CidrBlock: 10.0.0.0/19 193 | Tags: 194 | - Key: Name 195 | Value: saas-factory-pg-rls-subA-priv 196 | SubnetPrivateARouteTable: 197 | Type: AWS::EC2::SubnetRouteTableAssociation 198 | Properties: 199 | SubnetId: !Ref SubnetPrivateA 200 | RouteTableId: !Ref RouteTableNatA 201 | NatGatewayAddrB: 202 | Type: AWS::EC2::EIP 203 | DependsOn: AttachGateway 204 | Properties: 205 | Domain: vpc 206 | NatGatewayB: 207 | Type: AWS::EC2::NatGateway 208 | Properties: 209 | AllocationId: !GetAtt NatGatewayAddrB.AllocationId 210 | SubnetId: !Ref SubnetPublicB 211 | Tags: 212 | - Key: Name 213 | Value: saas-factory-pg-rls-nat-subB-pub 214 | RouteTableNatB: 215 | Type: AWS::EC2::RouteTable 216 | Properties: 217 | VpcId: !Ref VPC 218 | Tags: 219 | - Key: Name 220 | Value: saas-factory-pg-rls-route-natB 221 | RouteNatB: 222 | Type: AWS::EC2::Route 223 | DependsOn: NatGatewayB 224 | Properties: 225 | RouteTableId: !Ref RouteTableNatB 226 | DestinationCidrBlock: 0.0.0.0/0 227 | NatGatewayId: !Ref NatGatewayB 228 | SubnetPrivateB: 229 | Type: AWS::EC2::Subnet 230 | Properties: 231 | VpcId: !Ref VPC 232 | AvailabilityZone: !Select [1, !GetAZs ''] 233 | CidrBlock: 10.0.64.0/19 234 | Tags: 235 | - Key: Name 236 | Value: saas-factory-pg-rls-subB-priv 237 | SubnetPrivateBRouteTable: 238 | Type: AWS::EC2::SubnetRouteTableAssociation 239 | Properties: 240 | SubnetId: !Ref SubnetPrivateB 241 | RouteTableId: !Ref RouteTableNatB 242 | JumpBoxSecurityGroup: 243 | Type: AWS::EC2::SecurityGroup 244 | Properties: 245 | GroupName: saas-factory-pg-rls-ec2-sg 246 | GroupDescription: Jump Box SSH Security Group 247 | SecurityGroupIngress: 248 | - IpProtocol: tcp 249 | FromPort: 22 250 | ToPort: 22 251 | CidrIp: 0.0.0.0/0 252 | VpcId: !Ref VPC 253 | Tags: 254 | - Key: Name 255 | Value: saas-factory-pg-rls-ec2-sg 256 | JumpBox: 257 | Type: AWS::EC2::Instance 258 | Condition: HasKeyPair 259 | DependsOn: JumpBoxSecurityGroup 260 | Metadata: 261 | AWS::CloudFormation::Init: 262 | configSets: 263 | Setup: 264 | - Configure 265 | Configure: 266 | packages: 267 | yum: 268 | postgresql: [] 269 | commands: 270 | yum_update: 271 | command: yum update -y 272 | Properties: 273 | ImageId: !Ref AMI 274 | InstanceType: t2.micro 275 | KeyName: !Ref KeyPair 276 | NetworkInterfaces: 277 | - AssociatePublicIpAddress: true 278 | DeviceIndex: 0 279 | SubnetId: !Ref SubnetPublicA 280 | GroupSet: 281 | - !Ref JumpBoxSecurityGroup 282 | Tags: 283 | - Key: Name 284 | Value: saas-factory-pg-rls-jumpbox 285 | UserData: 286 | Fn::Base64: 287 | !Join 288 | - '' 289 | - - "#!/bin/bash -xe\n" 290 | - "yum update -y aws-cfn-bootstrap\n" 291 | - "# Run the config sets from the CloudFormation metadata\n" 292 | - "/opt/aws/bin/cfn-init -v -s " 293 | - !Ref AWS::StackName 294 | - " -r JumpBox -c Setup --region " 295 | - !Ref AWS::Region 296 | - "\n\n" 297 | LambdaExecutionRole: 298 | Type: AWS::IAM::Role 299 | Properties: 300 | RoleName: saas-factory-pg-rls-cfn-lambda-role 301 | Path: '/' 302 | AssumeRolePolicyDocument: 303 | Version: '2012-10-17' 304 | Statement: 305 | - Effect: Allow 306 | Principal: 307 | Service: 308 | - lambda.amazonaws.com 309 | Action: 310 | - sts:AssumeRole 311 | Policies: 312 | - PolicyName: saas-factory-pg-rls-cfn-lambda-policy 313 | PolicyDocument: 314 | Version: '2012-10-17' 315 | Statement: 316 | - Effect: Allow 317 | Action: 318 | - logs:CreateLogStream 319 | - logs:PutLogEvents 320 | Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:* 321 | - Effect: Allow 322 | Action: 323 | - logs:DescribeLogStreams 324 | Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:* 325 | - Effect: Allow 326 | Action: 327 | - ssm:PutParameter 328 | - ssm:GetParameter 329 | - ssm:DeleteParameter 330 | Resource: !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:* 331 | - Effect: Allow 332 | Action: 333 | - kms:Encrypt 334 | - kms:Decrypt 335 | - kms:ListKeys 336 | - kms:ListAliases 337 | - kms:Describe* 338 | Resource: !Sub arn:aws:kms:${AWS::Region}:${AWS::AccountId}:* 339 | - Effect: Allow 340 | Action: 341 | - ec2:CreateNetworkInterface 342 | - ec2:DescribeNetworkInterfaces 343 | - ec2:DeleteNetworkInterface 344 | Resource: '*' 345 | - Effect: Allow 346 | Action: 347 | - codebuild:StartBuild 348 | Resource: !Sub arn:aws:codebuild:${AWS::Region}:${AWS::AccountId}:project/* 349 | - Effect: Allow 350 | Action: 351 | - s3:ListBucket 352 | - s3:ListBucketVersions 353 | - s3:GetBucketVersioning 354 | Resource: 355 | - !Sub arn:aws:s3:::${CodePipelineBucket} 356 | - !Sub arn:aws:s3:::${CloudTrailBucket} 357 | - Effect: Allow 358 | Action: 359 | - s3:PutObject 360 | - s3:DeleteObject 361 | - s3:DeleteObjectVersion 362 | Resource: 363 | - !Sub arn:aws:s3:::${CodePipelineBucket}/* 364 | - !Sub arn:aws:s3:::${CloudTrailBucket}/* 365 | - Effect: Allow 366 | Action: 367 | - ecr:DescribeImages 368 | - ecr:BatchDeleteImage 369 | Resource: 370 | - !Sub arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/saas-factory-pg-rls 371 | LambdaSSMPutParamSecureLogs: 372 | Type: AWS::Logs::LogGroup 373 | Properties: 374 | LogGroupName: !Sub /aws/lambda/saas-factory-pg-rls-ssm-secure 375 | RetentionInDays: 14 376 | LambdaSSMPutParamSecure: 377 | Type: AWS::Lambda::Function 378 | DependsOn: LambdaSSMPutParamSecureLogs 379 | Properties: 380 | FunctionName: !Sub saas-factory-pg-rls-ssm-secure 381 | Role: !GetAtt LambdaExecutionRole.Arn 382 | Runtime: python3.7 383 | Timeout: 300 384 | MemorySize: 256 385 | Handler: index.lambda_handler 386 | Code: 387 | ZipFile: | 388 | import json 389 | import boto3 390 | import cfnresponse 391 | from botocore.exceptions import ClientError 392 | 393 | def lambda_handler(event, context): 394 | #print(json.dumps(event, default=str)) 395 | ssm = boto3.client('ssm') 396 | parameter_name = event['ResourceProperties']['Name'] 397 | parameter_value = event['ResourceProperties']['Value'] 398 | 399 | if event['RequestType'] in ['Create', 'Update']: 400 | try: 401 | put_response = ssm.put_parameter(Name=parameter_name, Value=parameter_value, Type='SecureString', Overwrite=True) 402 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {"Version": put_response['Version']}) 403 | except ClientError as ssm_error: 404 | print("ssm error %s" % str(ssm_error)) 405 | cfnresponse.send(event, context, cfnresponse.FAILED, {"Reason": str(ssm_error)}) 406 | raise 407 | elif event['RequestType'] == 'Delete': 408 | try: 409 | delete_response = ssm.delete_parameter(Name=parameter_name) 410 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) 411 | except ssm.exceptions.ParameterNotFound as not_found: 412 | # Ignore parameter not found 413 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) 414 | except ClientError as ssm_error: 415 | print("ssm error %s" % str(ssm_error)) 416 | cfnresponse.send(event, context, cfnresponse.FAILED, {"Reason": str(ssm_error)}) 417 | raise 418 | else: 419 | cfnresponse.send(event, context, cfnresponse.FAILED, {"Reason": "Unknown RequestType %s" % event['RequestType']}) 420 | InvokeLambdaSSMPutParamSecure: 421 | Type: Custom::CustomResource 422 | DependsOn: LambdaSSMPutParamSecureLogs 423 | Properties: 424 | ServiceToken: !GetAtt LambdaSSMPutParamSecure.Arn 425 | Name: saas-factory-pg-rls-owner-pw # SSM Parameter Name 426 | Value: !Ref DBMasterPassword 427 | InvokeLambdaSSMPutParamSecure2: 428 | Type: Custom::CustomResource 429 | DependsOn: LambdaSSMPutParamSecureLogs 430 | Properties: 431 | ServiceToken: !GetAtt LambdaSSMPutParamSecure.Arn 432 | Name: saas-factory-pg-rls-app-pw 433 | Value: !Ref DBAppPassword 434 | LambdaClearEcrImagesLogs: 435 | Type: AWS::Logs::LogGroup 436 | Properties: 437 | LogGroupName: !Sub /aws/lambda/saas-factory-pg-rls-clear-ecr 438 | RetentionInDays: 7 439 | LambdaClearEcrImages: 440 | Type: AWS::Lambda::Function 441 | DependsOn: LambdaClearEcrImagesLogs 442 | Properties: 443 | FunctionName: !Sub saas-factory-pg-rls-clear-ecr 444 | Role: !GetAtt LambdaExecutionRole.Arn 445 | Runtime: python3.7 446 | Timeout: 300 447 | MemorySize: 512 448 | Handler: index.lambda_handler 449 | Code: 450 | ZipFile: | 451 | import json 452 | import boto3 453 | import cfnresponse 454 | from botocore.exceptions import ClientError 455 | 456 | def lambda_handler(event, context): 457 | print(json.dumps(event, default=str)) 458 | if event['RequestType'] in ['Create', 'Update']: 459 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) 460 | elif event['RequestType'] == 'Delete': 461 | ecr = boto3.client('ecr') 462 | repo = event['ResourceProperties']['Repository'] 463 | try: 464 | images = [] 465 | token = None 466 | while True: 467 | if not token: 468 | images_response = ecr.describe_images(repositoryName=repo, maxResults=1000) 469 | else: 470 | images_response = ecr.describe_images(repositoryName=repo, nextToken=token, maxResults=1000) 471 | token = images_response['nextToken'] if 'nextToken' in images_response else '' 472 | if 'imageDetails' in images_response: 473 | for image_detail in images_response['imageDetails']: 474 | images.append({"imageDigest": image_detail['imageDigest']}) 475 | if not token: 476 | break 477 | print("Deleting %d images from repo %s" % (len(images), repo)) 478 | if len(images) > 0: 479 | delete_response = ecr.batch_delete_image(repositoryName=repo, imageIds=images) 480 | if 'failures' in delete_response and len(delete_response['failures']) > 0: 481 | for fail in delete_response['failures']: 482 | print("Failed to delete image %s %s in repo %s" % (fail['imageId']['imageDigest'], fail['failureReason'], repo)) 483 | cfnresponse.send(event, context, cfnresponse.FAILED, {"Reason": "ecr:BatchDeleteImage failed"}) 484 | else: 485 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) 486 | else: 487 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) 488 | except ClientError as ecr_error: 489 | print("ecr error %s" % str(ecr_error)) 490 | raise 491 | else: 492 | cfnresponse.send(event, context, cfnresponse.FAILED, {"Reason": "Unknown RequestType %s" % event['RequestType']}) 493 | RDSSecurityGroup: 494 | Type: AWS::EC2::SecurityGroup 495 | Properties: 496 | GroupName: saas-factory-pg-rls-rds-sg 497 | GroupDescription: RDS Aurora PostgreSQL 5432 Security Group 498 | VpcId: !Ref VPC 499 | Tags: 500 | - Key: Name 501 | Value: saas-factory-pg-rls-rds-sg 502 | RDSSecurityGroupIngressJumpBox: 503 | Type: AWS::EC2::SecurityGroupIngress 504 | Properties: 505 | GroupId: !Ref RDSSecurityGroup 506 | IpProtocol: tcp 507 | FromPort: 5432 508 | ToPort: 5432 509 | SourceSecurityGroupId: !Ref JumpBoxSecurityGroup 510 | RDSSecurityGroupIngressECS: 511 | Type: AWS::EC2::SecurityGroupIngress 512 | DependsOn: ECSSecurityGroup 513 | Properties: 514 | GroupId: !Ref RDSSecurityGroup 515 | IpProtocol: tcp 516 | FromPort: 5432 517 | ToPort: 5432 518 | SourceSecurityGroupId: !Ref ECSSecurityGroup 519 | RDSSubnetGroup: 520 | Type: AWS::RDS::DBSubnetGroup 521 | Properties: 522 | DBSubnetGroupDescription: saas-factory-pg-rls-rds-subnets 523 | DBSubnetGroupName: saas-factory-pg-rls-rds-subnets 524 | SubnetIds: 525 | - !Ref SubnetPrivateA 526 | - !Ref SubnetPrivateB 527 | RDSInstance: 528 | Type: AWS::RDS::DBInstance 529 | DependsOn: RDSSecurityGroup 530 | DeletionPolicy: Delete 531 | Properties: 532 | DBInstanceIdentifier: saas-factory-pg-rls-rds-instance 533 | DBInstanceClass: !Ref RDSInstanceType 534 | VPCSecurityGroups: 535 | - !Ref RDSSecurityGroup 536 | DBSubnetGroupName: !Ref RDSSubnetGroup 537 | DeleteAutomatedBackups: true 538 | MultiAZ: false 539 | Engine: postgres 540 | EngineVersion: !Ref RDSEngineVersion 541 | DBName: !Ref DBName 542 | MasterUsername: !Ref DBMasterUsername 543 | MasterUserPassword: 544 | Fn::Join: 545 | - '' 546 | - - '{{resolve:ssm-secure:saas-factory-pg-rls-owner-pw:' 547 | - !GetAtt InvokeLambdaSSMPutParamSecure.Version 548 | - '}}' 549 | AllocatedStorage: 20 550 | StorageType: gp2 551 | ECSRepository: 552 | Type: AWS::ECR::Repository 553 | Properties: 554 | RepositoryName: saas-factory-pg-rls 555 | InvokeClearEcrRepoImages: 556 | Type: Custom::CustomResource 557 | Properties: 558 | ServiceToken: !GetAtt LambdaClearEcrImages.Arn 559 | Repository: !Ref ECSRepository 560 | ECSCluster: 561 | Type: AWS::ECS::Cluster 562 | Properties: 563 | ClusterName: saas-factory-pg-rls 564 | ECSTaskExecutionRole: 565 | Type: AWS::IAM::Role 566 | Properties: 567 | RoleName: saas-factory-pg-rls-ecs-task-exec-role 568 | Path: '/' 569 | AssumeRolePolicyDocument: 570 | Version: '2012-10-17' 571 | Statement: 572 | - Effect: Allow 573 | Principal: 574 | Service: 575 | - ecs-tasks.amazonaws.com 576 | Action: 577 | - sts:AssumeRole 578 | Policies: 579 | - PolicyName: saas-factory-pg-rls-ecs-task-exec-policy 580 | PolicyDocument: 581 | Version: '2012-10-17' 582 | Statement: 583 | - Effect: Allow 584 | Action: 585 | - logs:PutLogEvents 586 | Resource: 587 | - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:*:log-stream:* 588 | - Effect: Allow 589 | Action: 590 | - logs:CreateLogStream 591 | Resource: 592 | - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:* 593 | - Effect: Allow 594 | Action: 595 | - ecr:BatchCheckLayerAvailability 596 | - ecr:GetDownloadUrlForLayer 597 | - ecr:BatchGetImage 598 | Resource: 599 | - !Sub arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/${ECSRepository} 600 | - Effect: Allow 601 | Action: 602 | - ecr:GetAuthorizationToken 603 | Resource: '*' 604 | - Effect: Allow 605 | Action: 606 | - ssm:GetParameters 607 | Resource: 608 | - !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/saas-factory-pg-rls-owner-pw 609 | - !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/saas-factory-pg-rls-app-pw 610 | ECSLogGroup: 611 | Type: AWS::Logs::LogGroup 612 | Properties: 613 | LogGroupName: '/ecs/saas-factory-pg-rls-app' 614 | RetentionInDays: 14 615 | ECSTaskRole: 616 | Type: AWS::IAM::Role 617 | Properties: 618 | RoleName: saas-factory-pg-rls-app-task-role 619 | Path: '/' 620 | AssumeRolePolicyDocument: 621 | Version: '2012-10-17' 622 | Statement: 623 | - Effect: Allow 624 | Principal: 625 | Service: 626 | - ecs-tasks.amazonaws.com 627 | Action: 628 | - sts:AssumeRole 629 | ECSTaskDefinition: 630 | Type: AWS::ECS::TaskDefinition 631 | Properties: 632 | Family: saas-factory-pg-rls-app 633 | ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn 634 | TaskRoleArn: !GetAtt ECSTaskRole.Arn 635 | RequiresCompatibilities: 636 | - FARGATE 637 | Memory: 1024 638 | Cpu: 512 639 | NetworkMode: awsvpc 640 | ContainerDefinitions: 641 | - Name: saas-factory-pg-rls-app 642 | Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECSRepository}:latest 643 | PortMappings: 644 | - ContainerPort: 8080 645 | LogConfiguration: 646 | LogDriver: awslogs 647 | Options: 648 | awslogs-group: !Ref ECSLogGroup 649 | awslogs-region: !Ref AWS::Region 650 | awslogs-stream-prefix: ecs 651 | Environment: 652 | - Name: AWS_REGION 653 | Value: !Ref AWS::Region 654 | - Name: DB_HOST 655 | Value: !GetAtt RDSInstance.Endpoint.Address 656 | - Name: DB_NAME 657 | Value: !Ref DBName 658 | - Name: DB_USER 659 | Value: !Ref DBAppUsername 660 | - Name: DB_ADMIN_USER 661 | Value: !Ref DBMasterUsername 662 | Secrets: 663 | - Name: DB_ADMIN_PASS 664 | ValueFrom: !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/saas-factory-pg-rls-owner-pw 665 | - Name: DB_PASS 666 | ValueFrom: !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/saas-factory-pg-rls-app-pw 667 | ECSSecurityGroup: 668 | Type: AWS::EC2::SecurityGroup 669 | DependsOn: VPC 670 | Properties: 671 | GroupName: saas-factory-pg-rls-ecs-sg 672 | GroupDescription: Access to Fargate Containers 673 | VpcId: !Ref VPC 674 | ECSSecurityGroupIngress: 675 | Type: AWS::EC2::SecurityGroupIngress 676 | DependsOn: 677 | - ECSSecurityGroup 678 | - ALBSecurityGroup 679 | Properties: 680 | GroupId: !Ref ECSSecurityGroup 681 | SourceSecurityGroupId: !Ref ALBSecurityGroup 682 | IpProtocol: -1 683 | Tags: 684 | - Key: Name 685 | Value: saas-factory-pg-rls-ecs-sg 686 | ALBSecurityGroup: 687 | Type: AWS::EC2::SecurityGroup 688 | DependsOn: VPC 689 | Properties: 690 | GroupName: saas-factory-pg-rls-alb-sg 691 | GroupDescription: Access to the load balancer 692 | VpcId: !Ref VPC 693 | SecurityGroupIngress: 694 | - CidrIp: 0.0.0.0/0 695 | IpProtocol: tcp 696 | FromPort: 80 697 | ToPort: 80 698 | ECSLoadBalancer: 699 | Type: AWS::ElasticLoadBalancingV2::LoadBalancer 700 | Properties: 701 | Scheme: internet-facing 702 | LoadBalancerAttributes: 703 | - Key: idle_timeout.timeout_seconds 704 | Value: 30 705 | Subnets: 706 | - !Ref SubnetPublicA 707 | - !Ref SubnetPublicB 708 | SecurityGroups: [!Ref ALBSecurityGroup] 709 | ALBTargetGroup: 710 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 711 | Properties: 712 | Name: saas-factory-pg-rls-target-group 713 | HealthCheckProtocol: HTTP 714 | HealthCheckPath: '/health' 715 | HealthCheckIntervalSeconds: 30 716 | HealthCheckTimeoutSeconds: 5 717 | HealthyThresholdCount: 2 718 | UnhealthyThresholdCount: 2 719 | Port: 8080 720 | Protocol: HTTP 721 | TargetType: ip 722 | VpcId: !Ref VPC 723 | TargetGroupAttributes: 724 | - Key: stickiness.enabled 725 | Value: 'true' 726 | - Key: stickiness.type 727 | Value: lb_cookie 728 | - Key: stickiness.lb_cookie.duration_seconds 729 | Value: '86400' 730 | ALBListener: 731 | Type: AWS::ElasticLoadBalancingV2::Listener 732 | DependsOn: ECSLoadBalancer 733 | Properties: 734 | LoadBalancerArn: !Ref ECSLoadBalancer 735 | DefaultActions: 736 | - TargetGroupArn: !Ref ALBTargetGroup 737 | Type: forward 738 | Port: 80 739 | Protocol: HTTP 740 | ALBRule: 741 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 742 | Properties: 743 | Actions: 744 | - TargetGroupArn: !Ref ALBTargetGroup 745 | Type: forward 746 | Conditions: 747 | - Field: path-pattern 748 | Values: ['*'] 749 | ListenerArn: !Ref ALBListener 750 | Priority: 1 751 | ECSService: 752 | Type: AWS::ECS::Service 753 | DependsOn: 754 | - ECSTaskDefinition 755 | - ALBRule 756 | - InvokeLambdaCodeBuildStartBuild 757 | Properties: 758 | ServiceName: saas-factory-pg-rls-app 759 | Cluster: !Ref ECSCluster 760 | TaskDefinition: !Ref ECSTaskDefinition 761 | LaunchType: FARGATE 762 | DesiredCount: 1 763 | NetworkConfiguration: 764 | AwsvpcConfiguration: 765 | SecurityGroups: 766 | - !Ref ECSSecurityGroup 767 | Subnets: 768 | - !Ref SubnetPrivateA 769 | - !Ref SubnetPrivateB 770 | LoadBalancers: 771 | - ContainerName: saas-factory-pg-rls-app 772 | ContainerPort: 8080 773 | TargetGroupArn: !Ref ALBTargetGroup 774 | CodeBuildRole: 775 | Type: AWS::IAM::Role 776 | Properties: 777 | RoleName: saas-factory-pg-rls-cfn-codebuild-role 778 | Path: '/' 779 | AssumeRolePolicyDocument: 780 | Version: '2012-10-17' 781 | Statement: 782 | - Effect: Allow 783 | Principal: 784 | Service: 785 | - codebuild.amazonaws.com 786 | Action: 787 | - sts:AssumeRole 788 | Policies: 789 | - PolicyName: saas-factory-pg-rls-cfn-codebuild-policy 790 | PolicyDocument: 791 | Version: '2012-10-17' 792 | Statement: 793 | - Effect: Allow 794 | Action: 795 | - logs:CreateLogGroup 796 | - logs:CreateLogStream 797 | - logs:PutLogEvents 798 | Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:* 799 | - Effect: Allow 800 | Action: 801 | - ecr:GetAuthorizationToken 802 | Resource: '*' 803 | - Effect: Allow 804 | Action: 805 | - s3:PutObject 806 | - s3:GetObject 807 | - s3:GetObjectVersion 808 | - s3:GetBucketVersioning 809 | Resource: 810 | - !Sub arn:aws:s3:::${CodePipelineBucket} 811 | - !Sub arn:aws:s3:::${CodePipelineBucket}/* 812 | - Effect: Allow 813 | Action: 814 | - ecr:GetDownloadUrlForLayer 815 | - ecr:BatchGetImage 816 | - ecr:BatchCheckLayerAvailability 817 | - ecr:PutImage 818 | - ecr:InitiateLayerUpload 819 | - ecr:UploadLayerPart 820 | - ecr:CompleteLayerUpload 821 | Resource: !Sub arn:aws:ecr:${AWS::Region}:${AWS::AccountId}:repository/${ECSRepository} 822 | ClearS3BucketLogs: 823 | Type: AWS::Logs::LogGroup 824 | Properties: 825 | LogGroupName: !Sub /aws/lambda/saas-factory-pg-rls-s3-clear 826 | RetentionInDays: 30 827 | ClearS3Bucket: 828 | Type: AWS::Lambda::Function 829 | DependsOn: ClearS3BucketLogs 830 | Properties: 831 | FunctionName: !Sub saas-factory-pg-rls-s3-clear 832 | Role: !GetAtt LambdaExecutionRole.Arn 833 | Runtime: python3.7 834 | Timeout: 900 835 | MemorySize: 512 836 | Handler: index.lambda_handler 837 | Code: 838 | ZipFile: | 839 | import json 840 | import boto3 841 | import cfnresponse 842 | from botocore.exceptions import ClientError 843 | 844 | def lambda_handler(event, context): 845 | print(json.dumps(event, default=str)) 846 | if event['RequestType'] in ['Create', 'Update']: 847 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) 848 | elif event['RequestType'] == 'Delete': 849 | s3 = boto3.client('s3') 850 | bucket = event['ResourceProperties']['Bucket'] 851 | objects_to_delete = [] 852 | try: 853 | versioning_response = s3.get_bucket_versioning(Bucket=bucket) 854 | print(json.dumps(versioning_response, default=str)) 855 | if 'Status' in versioning_response and versioning_response['Status'] in ['Enabled', 'Suspended']: 856 | print("Bucket %s is versioned (%s)" % (bucket, versioning_response['Status'])) 857 | key_token = '' 858 | version_token = '' 859 | while True: 860 | if not version_token: 861 | list_response = s3.list_object_versions(Bucket=bucket, KeyMarker=key_token) 862 | else: 863 | list_response = s3.list_object_versions(Bucket=bucket, KeyMarker=key_token, VersionIdMarker=version_token) 864 | key_token = list_response['NextKeyMarker'] if 'NextKeyMarker' in list_response else '' 865 | version_token = list_response['NextVersionIdMarker'] if 'NextVersionIdMarker' in list_response else '' 866 | if 'Versions' in list_response: 867 | for s3_object in list_response['Versions']: 868 | objects_to_delete.append({'Key': s3_object['Key'], 'VersionId': s3_object['VersionId']}) 869 | if not list_response['IsTruncated']: 870 | break 871 | else: 872 | print("Bucket %s is not versioned" % bucket) 873 | token = '' 874 | while True: 875 | if not token: 876 | list_response = s3.list_objects_v2(Bucket=bucket) 877 | else: 878 | list_response = s3.list_objects_v2(Bucket=bucket, ContinuationToken=token) 879 | token = list_response['NextContinuationToken'] if 'NextContinuationToken' in list_response else '' 880 | if 'Contents' in list_response: 881 | for s3_object in list_response['Contents']: 882 | objects_to_delete.append({'Key': s3_object['Key']}) 883 | if not list_response['IsTruncated']: 884 | break 885 | if len(objects_to_delete) > 0: 886 | print("Deleting %d objects" % len(objects_to_delete)) 887 | max_batch_size = 1000 888 | batch_start = 0 889 | batch_end = 0 890 | while batch_end < len(objects_to_delete): 891 | batch_start = batch_end 892 | batch_end += max_batch_size 893 | if (batch_end > len(objects_to_delete)): 894 | batch_end = len(objects_to_delete) 895 | delete_response = s3.delete_objects(Bucket=bucket, Delete={'Objects': objects_to_delete[batch_start:batch_end]}) 896 | print("Cleaned up %d objects in bucket %s" % (len(delete_response['Deleted']), bucket)) 897 | else: 898 | print("Bucket %s is empty. Nothing to clean up." % bucket) 899 | 900 | # Tell CloudFormation we're all done 901 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) 902 | except ClientError as s3_error: 903 | cfnresponse.send(event, context, cfnresponse.FAILED, {"Reason": str(s3_error)}) 904 | raise 905 | else: 906 | cfnresponse.send(event, context, cfnresponse.FAILED, {"Reason": "Unknown RequestType %s" % event['RequestType']}) 907 | InvokeClearS3BucketCodePipelineBucket: 908 | Type: Custom::CustomResource 909 | DependsOn: 910 | - ClearS3Bucket 911 | - CodePipelineBucket 912 | - ClearS3BucketLogs 913 | Properties: 914 | ServiceToken: !GetAtt ClearS3Bucket.Arn 915 | Bucket: !Ref CodePipelineBucket 916 | InvokeClearS3BucketCloudTrailBucket: 917 | Type: Custom::CustomResource 918 | DependsOn: 919 | - ClearS3Bucket 920 | - CloudTrailBucket 921 | - ClearS3BucketLogs 922 | Properties: 923 | ServiceToken: !GetAtt ClearS3Bucket.Arn 924 | Bucket: !Ref CloudTrailBucket 925 | CodePipelineBucket: 926 | Type: AWS::S3::Bucket 927 | Properties: 928 | VersioningConfiguration: 929 | Status: Enabled 930 | Tags: 931 | - Key: Name 932 | Value: saas-factory-pg-rls-pipeline-bucket 933 | CloudTrailBucket: 934 | Type: AWS::S3::Bucket 935 | Properties: 936 | Tags: 937 | - Key: Name 938 | Value: saas-factory-pg-rls-cloudtrail-bucket 939 | CloudTrailBucketPolicy: 940 | Type: AWS::S3::BucketPolicy 941 | DependsOn: CloudTrailBucket 942 | Properties: 943 | Bucket: 944 | Ref: CloudTrailBucket 945 | PolicyDocument: 946 | Version: '2012-10-17' 947 | Statement: 948 | - Sid: AWSCloudTrailAclCheck20150319 949 | Effect: Allow 950 | Principal: 951 | Service: cloudtrail.amazonaws.com 952 | Action: s3:GetBucketAcl 953 | Resource: !GetAtt CloudTrailBucket.Arn 954 | - Sid: AWSCloudTrailWrite20150319 955 | Effect: Allow 956 | Principal: 957 | Service: cloudtrail.amazonaws.com 958 | Action: s3:PutObject 959 | Resource: !Sub arn:aws:s3:::${CloudTrailBucket}/AWSLogs/${AWS::AccountId}/* 960 | Condition: 961 | StringEquals: 962 | s3:x-amz-acl: bucket-owner-full-control 963 | CloudTrailForCodePipelineTrigger: 964 | Type: AWS::CloudTrail::Trail 965 | DependsOn: CloudTrailBucketPolicy 966 | Properties: 967 | TrailName: saas-factory-pg-rls-codebuild-trail 968 | S3BucketName: !Ref CloudTrailBucket 969 | IsLogging: true 970 | EventSelectors: 971 | - IncludeManagementEvents: false 972 | ReadWriteType: WriteOnly 973 | DataResources: 974 | - Type: AWS::S3::Object 975 | Values: 976 | - !Sub arn:aws:s3:::${CodePipelineBucket}/saas-factory-pg-rls-app 977 | CloudWatchEventRoleForCloudTrail: 978 | Type: AWS::IAM::Role 979 | Properties: 980 | RoleName: saas-factory-pg-rls-cfn-cloudwatch-event-role 981 | Path: '/' 982 | AssumeRolePolicyDocument: 983 | Version: '2012-10-17' 984 | Statement: 985 | - Effect: Allow 986 | Principal: 987 | Service: 988 | - events.amazonaws.com 989 | Action: 990 | - sts:AssumeRole 991 | Policies: 992 | - PolicyName: saas-factory-pg-rls-cfn-cloudwatch-event-policy 993 | PolicyDocument: 994 | Version: '2012-10-17' 995 | Statement: 996 | - Effect: Allow 997 | Action: 998 | - codepipeline:StartPipelineExecution 999 | Resource: !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${CodePipelineDeploy} 1000 | CloudWatchEventRuleForCodePipeline: 1001 | Type: AWS::Events::Rule 1002 | DependsOn: CloudWatchEventRoleForCloudTrail 1003 | Properties: 1004 | EventPattern: 1005 | source: 1006 | - aws.s3 1007 | detail-type: 1008 | - 'AWS API Call via CloudTrail' 1009 | detail: 1010 | eventSource: 1011 | - s3.amazonaws.com 1012 | eventName: 1013 | - CopyObject 1014 | - PutObject 1015 | - CompleteMultipartUpload 1016 | requestParameters: 1017 | bucketName: 1018 | - !Ref CodePipelineBucket 1019 | key: 1020 | - saas-factory-pg-rls-app 1021 | Targets: 1022 | - Arn: !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${CodePipelineDeploy} 1023 | RoleArn: !GetAtt CloudWatchEventRoleForCloudTrail.Arn 1024 | Id: saas-factory-pg-rls-app-deploy 1025 | CodeBuildProject: 1026 | Type: AWS::CodeBuild::Project 1027 | Properties: 1028 | Name: saas-factory-pg-rls-app 1029 | Tags: 1030 | - Key: Name 1031 | Value: saas-factory-pg-rls-app 1032 | ServiceRole: !GetAtt CodeBuildRole.Arn 1033 | TimeoutInMinutes: 10 1034 | Artifacts: 1035 | Type: S3 1036 | Location: !Ref CodePipelineBucket 1037 | Path: '/' 1038 | Name: saas-factory-pg-rls-app 1039 | Packaging: ZIP 1040 | Environment: 1041 | ComputeType: BUILD_GENERAL1_SMALL 1042 | Image: aws/codebuild/amazonlinux2-x86_64-standard:3.0 1043 | Type: LINUX_CONTAINER 1044 | PrivilegedMode: true 1045 | EnvironmentVariables: 1046 | - Name: REPOSITORY_URI 1047 | Value: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECSRepository} 1048 | Source: 1049 | Type: NO_SOURCE 1050 | BuildSpec: | 1051 | version: 0.2 1052 | phases: 1053 | install: 1054 | runtime-versions: 1055 | java: corretto11 1056 | pre_build: 1057 | commands: 1058 | - mkdir pgrls 1059 | - cd pgrls 1060 | - git init 1061 | - git pull https://github.com/aws-samples/aws-saas-factory-postgresql-rls.git 1062 | - cd ../ 1063 | build: 1064 | commands: 1065 | - cd pgrls/app 1066 | - mvn clean package 1067 | - docker image build -t pgrls -f Dockerfile . 1068 | - docker tag pgrls:latest "${REPOSITORY_URI}:latest" 1069 | - cd ../../ 1070 | post_build: 1071 | commands: 1072 | - aws ecr get-login --no-include-email --region $AWS_REGION | awk '{print $6}' | docker login -u AWS --password-stdin $REPOSITORY_URI 1073 | - docker push "${REPOSITORY_URI}:latest" 1074 | - printf '[{"name":"saas-factory-pg-rls-app","imageUri":"%s"}]' ${REPOSITORY_URI}:latest > imagedefinitions.json 1075 | artifacts: 1076 | files: imagedefinitions.json 1077 | discard-paths: yes 1078 | CodePipelineRole: 1079 | Type: AWS::IAM::Role 1080 | DependsOn: CodePipelineBucket 1081 | Properties: 1082 | RoleName: saas-factory-pg-rls-cfn-codepipeline-role 1083 | Path: '/' 1084 | AssumeRolePolicyDocument: 1085 | Version: '2012-10-17' 1086 | Statement: 1087 | - Effect: Allow 1088 | Principal: 1089 | Service: 1090 | - codepipeline.amazonaws.com 1091 | Action: 1092 | - sts:AssumeRole 1093 | ManagedPolicyArns: 1094 | - arn:aws:iam::aws:policy/AmazonECS_FullAccess 1095 | Policies: 1096 | - PolicyName: saas-factory-pg-rls-cfn-codepipeline-policy 1097 | PolicyDocument: 1098 | Version: '2012-10-17' 1099 | Statement: 1100 | - Effect: Allow 1101 | Action: 1102 | - iam:PassRole 1103 | Resource: '*' 1104 | Condition: 1105 | StringEqualsIfExists: 1106 | iamPassedToService: 1107 | - ecs-tasks.amazonaws.com 1108 | - Effect: Allow 1109 | Action: 1110 | - s3:PutObject 1111 | - s3:GetObject 1112 | - s3:GetObjectVersion 1113 | - s3:GetBucketVersioning 1114 | Resource: 1115 | - !Sub arn:aws:s3:::${CodePipelineBucket} 1116 | - !Sub arn:aws:s3:::${CodePipelineBucket}/* 1117 | CodePipelineDeploy: 1118 | Type: AWS::CodePipeline::Pipeline 1119 | Properties: 1120 | Name: saas-factory-pg-rls-app-deploy 1121 | RoleArn: !GetAtt CodePipelineRole.Arn 1122 | ArtifactStore: 1123 | Location: !Ref CodePipelineBucket 1124 | Type: S3 1125 | Stages: 1126 | - Name: Source 1127 | Actions: 1128 | - Name: SourceAction 1129 | ActionTypeId: 1130 | Category: Source 1131 | Owner: AWS 1132 | Provider: S3 1133 | Version: 1 1134 | Configuration: 1135 | S3Bucket: !Ref CodePipelineBucket 1136 | S3ObjectKey: saas-factory-pg-rls-app 1137 | PollForSourceChanges: false 1138 | OutputArtifacts: 1139 | - Name: imgdef 1140 | - Name: Deploy 1141 | Actions: 1142 | - Name: DeployAction 1143 | ActionTypeId: 1144 | Category: Deploy 1145 | Owner: AWS 1146 | Provider: ECS 1147 | Version: 1 1148 | Configuration: 1149 | ClusterName: !Ref ECSCluster 1150 | ServiceName: saas-factory-pg-rls-app 1151 | FileName: imagedefinitions.json 1152 | InputArtifacts: 1153 | - Name: imgdef 1154 | LambdaCodeBuildStartBuildLogs: 1155 | Type: AWS::Logs::LogGroup 1156 | Properties: 1157 | LogGroupName: !Sub /aws/lambda/saas-factory-pg-rls-codebuild-start 1158 | RetentionInDays: 14 1159 | LambdaCodeBuildStartBuild: 1160 | Type: AWS::Lambda::Function 1161 | DependsOn: LambdaCodeBuildStartBuildLogs 1162 | Properties: 1163 | FunctionName: !Sub saas-factory-pg-rls-codebuild-start 1164 | Role: !GetAtt LambdaExecutionRole.Arn 1165 | Runtime: python3.7 1166 | Timeout: 60 1167 | MemorySize: 512 1168 | Handler: index.lambda_handler 1169 | Code: 1170 | ZipFile: | 1171 | import json 1172 | import boto3 1173 | import cfnresponse 1174 | from botocore.exceptions import ClientError 1175 | 1176 | def lambda_handler(event, context): 1177 | print(json.dumps(event, default=str)) 1178 | if event['RequestType'] == 'Create': 1179 | try: 1180 | codebuild = boto3.client('codebuild') 1181 | response = codebuild.start_build(projectName = event['ResourceProperties']['Project']) 1182 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {"BuildStatus": response['build']['buildStatus']}) 1183 | except ClientError as codebuild_error: 1184 | cfnresponse.send(event, context, cfnresponse.FAILED, {"Reason": str(codebuild_error)}) 1185 | raise 1186 | elif event['RequestType'] in ['Update', 'Delete']: 1187 | cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) 1188 | else: 1189 | cfnresponse.send(event, context, cfnresponse.FAILED, {"Reason": "Unknown RequestType %s" % event['RequestType']}) 1190 | InvokeLambdaCodeBuildStartBuild: 1191 | Type: Custom::CustomResource 1192 | DependsOn: 1193 | - CodeBuildProject 1194 | - ECSRepository 1195 | - LambdaCodeBuildStartBuildLogs 1196 | Properties: 1197 | ServiceToken: !GetAtt LambdaCodeBuildStartBuild.Arn 1198 | Project: saas-factory-pg-rls-app 1199 | Outputs: 1200 | LoadBalancerEndpoint: 1201 | Description: Load balancer URL 1202 | Value: !GetAtt ECSLoadBalancer.DNSName 1203 | RDSEndpoint: 1204 | Description: RDS Endpoint 1205 | Value: !GetAtt RDSInstance.Endpoint.Address 1206 | RDSDatabaseName: 1207 | Description: Database Name 1208 | Value: !Ref DBName 1209 | RDSDatabaseMasterUser: 1210 | Description: Master Database User 1211 | Value: !Ref DBMasterUsername 1212 | RDSDatabaseAppUser: 1213 | Description: Application Database User 1214 | Value: !Ref DBAppUsername 1215 | JumpBoxDNS: 1216 | Condition: HasKeyPair 1217 | Description: Jump Box DNS 1218 | Value: !GetAtt JumpBox.PublicDnsName 1219 | ... --------------------------------------------------------------------------------